From 16ef7eacb50a14f4d2beb4ee51ce8eb54b7e4343 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 25 Mar 2026 14:07:47 -0500 Subject: [PATCH 001/164] add ao support --- src/texturelab/mainwindow.cpp | 9 +++++++++ src/texturelab/models.h | 3 ++- src/texturelab/project.cpp | 2 ++ .../widgets/properties/propertieswidget.cpp | 2 +- src/viewer3d/renderer/renderer.cpp | 8 ++++++++ src/viewer3d/renderer/renderer.h | 1 + src/viewer3d/viewer3d.cpp | 13 +++++++++++++ src/viewer3d/viewer3d.h | 7 ++----- 8 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index ddebb137..0afdd272 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -167,6 +167,12 @@ void MainWindow::passTextureChannelsToViewer3D() case TextureChannel::Roughness: viewer->setRoughnessTexture(node->textureId()); break; + case TextureChannel::Height: + viewer->setHeightTexture(node->textureId()); + break; + case TextureChannel::AO: + viewer->setAoTexture(node->textureId()); + break; default: break; } @@ -222,6 +228,9 @@ void MainWindow::setProject(TextureProjectPtr project) case TextureChannel::Height: viewer->setHeightTexture(texId); break; + case TextureChannel::AO: + viewer->setAoTexture(texId); + break; default: break; } diff --git a/src/texturelab/models.h b/src/texturelab/models.h index 130984d9..02833df5 100644 --- a/src/texturelab/models.h +++ b/src/texturelab/models.h @@ -48,7 +48,8 @@ enum class TextureChannel : int { Metalness = 3, Roughness = 4, Height = 5, - Alpha = 6 + Alpha = 6, + AO = 7 }; class ProjectFile { diff --git a/src/texturelab/project.cpp b/src/texturelab/project.cpp index 2cd05537..18501eeb 100644 --- a/src/texturelab/project.cpp +++ b/src/texturelab/project.cpp @@ -137,6 +137,8 @@ TextureProjectPtr Project::loadTexture(QString path) channel = TextureChannel::Height; else if (key == "alpha") channel = TextureChannel::Alpha; + else if (key == "ao") + channel = TextureChannel::AO; if (channel != TextureChannel::None) { auto nodeId = channels[key].toString(); diff --git a/src/texturelab/widgets/properties/propertieswidget.cpp b/src/texturelab/widgets/properties/propertieswidget.cpp index 3465d4da..ce088f20 100644 --- a/src/texturelab/widgets/properties/propertieswidget.cpp +++ b/src/texturelab/widgets/properties/propertieswidget.cpp @@ -12,7 +12,7 @@ PropertiesWidget::PropertiesWidget() : QWidget() textureChannelProp = new EnumProp(); textureChannelProp->displayName = "Texture Channel"; textureChannelProp->values = {"None", "Albedo", "Normal", "Metalness", - "Roughness", "Height", "Alpha"}; + "Roughness", "Height", "Alpha", "AO"}; textureChannelProp->setValue(0); randomSeedProp = new IntProp(); diff --git a/src/viewer3d/renderer/renderer.cpp b/src/viewer3d/renderer/renderer.cpp index 4ec5d0dc..ff5a1133 100644 --- a/src/viewer3d/renderer/renderer.cpp +++ b/src/viewer3d/renderer/renderer.cpp @@ -91,6 +91,8 @@ void Renderer::updateMaterial(Material* material) flags << "HAS_ROUGHNESS_MAP 1"; if (material->heightMapId != 0) flags << "HAS_HEIGHT_MAP 1"; + if (material->aoMapId != 0) + flags << "HAS_OCCLUSION_MAP 1"; // flags << "HAS_NORMAL_MAP 1"; // flags << "HAS_ROUGHNESS_MAP 1"; // flags << "HAS_METALNESS_MAP 1"; @@ -319,6 +321,12 @@ void Renderer::renderGltfMesh(Mesh* mesh, Material* material, shader->setUniformValue("u_HeightScale", material->heightScale); + shader->setUniformValue("u_OcclusionSampler", 5); + gl->glActiveTexture(GL_TEXTURE5); + gl->glBindTexture(GL_TEXTURE_2D, mat->aoMapId); + shader->setUniformValue("u_OcclusionUVSet", 0); + shader->setUniformValue("u_OcclusionStrength", 1.0f); + // albedo // mainProgram->setUniformValue("u_BaseColorFactor", mat->albedo); // shader->setUniformValue("u_BaseColorSampler", 0); diff --git a/src/viewer3d/renderer/renderer.h b/src/viewer3d/renderer/renderer.h index 7857aa5f..735b9ea3 100644 --- a/src/viewer3d/renderer/renderer.h +++ b/src/viewer3d/renderer/renderer.h @@ -64,6 +64,7 @@ struct Material { GLuint metalnessMapId = 0; GLuint roughnessMapId = 0; GLuint heightMapId = 0; + GLuint aoMapId = 0; // QOpenGLTexture* albedoMap = nullptr; // QOpenGLTexture* normalMap = nullptr; diff --git a/src/viewer3d/viewer3d.cpp b/src/viewer3d/viewer3d.cpp index cb108884..b866c41c 100644 --- a/src/viewer3d/viewer3d.cpp +++ b/src/viewer3d/viewer3d.cpp @@ -524,6 +524,18 @@ void Viewer3D::setHeightScale(float scale) this->material->needsUpdate = true; } +void Viewer3D::setAoTexture(GLuint texId) +{ + this->material->aoMapId = texId; + this->material->needsUpdate = true; +} + +void Viewer3D::clearAoTexture() +{ + this->material->aoMapId = 0; + this->material->needsUpdate = true; +} + void Viewer3D::clearTextures() { this->clearAlbedoTexture(); @@ -531,6 +543,7 @@ void Viewer3D::clearTextures() this->clearMetalnessTexture(); this->clearRoughnessTexture(); this->clearHeightTexture(); + this->clearAoTexture(); } void Viewer3D::resetCamera() diff --git a/src/viewer3d/viewer3d.h b/src/viewer3d/viewer3d.h index e2013e04..4c0afee1 100644 --- a/src/viewer3d/viewer3d.h +++ b/src/viewer3d/viewer3d.h @@ -106,13 +106,10 @@ class Viewer3D : public QOpenGLWidget { void setHeightTexture(GLuint texId); void clearHeightTexture(); void setHeightScale(float scale); + void setAoTexture(GLuint texId); + void clearAoTexture(); void resetMaterial(); - // void setAlphaTexture(GLuint texId); - // void setAoTexture(GLuint texId); - // void setEmissiveTexture(GLuint texId); - // void setHeightTexture(GLuint texId); - void clearTextures(); void resetCamera(); void loadEnvironment(const QString path); From eaf29632505620d4f9eba5d1a0aad32f66b66585 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 25 Mar 2026 14:37:38 -0500 Subject: [PATCH 002/164] add new library and ao shader --- src/texturelab/CMakeLists.txt | 7 +- src/texturelab/libraries/library.cpp | 12 ++++ src/texturelab/libraries/library.h | 1 + src/texturelab/libraries/libv3.h | 8 +++ .../libraries/v3/ambientocclusion.cpp | 67 +++++++++++++++++++ src/texturelab/models.cpp | 2 +- src/texturelab/project.cpp | 2 +- 7 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 src/texturelab/libraries/libv3.h create mode 100644 src/texturelab/libraries/v3/ambientocclusion.cpp diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 0a804e99..5b1d5b90 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -98,6 +98,10 @@ set(LIBRARYV2 ./libraries/v2/warpv2.cpp ) +set(LIBRARYV3 + ./libraries/v3/ambientocclusion.cpp +) + set(PROJECT_SOURCES ./main.cpp ./mainwindow.cpp @@ -112,7 +116,7 @@ set(PROJECT_SOURCES ./props.h ./props.cpp ./libraries/libv2.h - ./libraries/libv2.h + ./libraries/libv3.h ./libraries/library.h ./libraries/library.cpp ./widgets/graphwidget.h @@ -137,6 +141,7 @@ set(PROJECT_SOURCES ./graphics/renderworker.cpp ${LIBRARYV1} ${LIBRARYV2} + ${LIBRARYV3} ) set(PROJECT_RESOURCES diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index 863dc04a..4b65894c 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -2,6 +2,7 @@ #include "../models.h" #include "libv1.h" #include "libv2.h" +#include "libv3.h" #include @@ -181,5 +182,16 @@ Library* createLibraryV2() ":nodes/valuenoisefractalsum.png"); lib->addNode("warp", "Warp", ":nodes/warp.png"); + return lib; +} + +Library* createLibraryV3() +{ + auto lib = createLibraryV2(); + + // V3 NODES + lib->addNode("ambientocclusion", "Ambient Occlusion", + ":nodes/bevel.png"); + return lib; } \ No newline at end of file diff --git a/src/texturelab/libraries/library.h b/src/texturelab/libraries/library.h index 9e159147..aedefa65 100644 --- a/src/texturelab/libraries/library.h +++ b/src/texturelab/libraries/library.h @@ -41,6 +41,7 @@ class Library }; Library *createLibraryV2(); +Library *createLibraryV3(); class LibraryV1 : public Library { diff --git a/src/texturelab/libraries/libv3.h b/src/texturelab/libraries/libv3.h new file mode 100644 index 00000000..3dddbfc9 --- /dev/null +++ b/src/texturelab/libraries/libv3.h @@ -0,0 +1,8 @@ +#pragma once + +#include "../models.h" + +class AmbientOcclusionNode : public TextureNode { +public: + virtual void init() override; +}; diff --git a/src/texturelab/libraries/v3/ambientocclusion.cpp b/src/texturelab/libraries/v3/ambientocclusion.cpp new file mode 100644 index 00000000..a50b3591 --- /dev/null +++ b/src/texturelab/libraries/v3/ambientocclusion.cpp @@ -0,0 +1,67 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void AmbientOcclusionNode::init() +{ + this->title = "Ambient Occlusion"; + this->addInput("height"); + + this->addFloatProp("radius", "Radius", 0.05, 0.001, 1.0, 0.005); + this->addIntProp("samples", "Samples", 16, 4, 64, 4); + this->addFloatProp("intensity", "Intensity", 1.0, 0.1, 5.0, 0.1); + this->addFloatProp("bias", "Bias", 0.01, 0.0, 0.1, 0.005); + this->addFloatProp("height_scale", "Height Scale", 0.1, 0.01, 1.0, 0.01); + + auto source = R""""( + // Hash function for pseudo-random sampling directions + float hash(vec2 p) + { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); + } + + vec2 sampleDirection(float i, vec2 uv) + { + float angle = hash(uv + i * 7.13) * 6.28318530718; + return vec2(cos(angle), sin(angle)); + } + + vec4 process(vec2 uv) + { + float centerH = texture(height, uv).r * prop_height_scale; + float occlusion = 0.0; + float sampleCount = float(prop_samples); + float radius = prop_radius; + + for (int i = 0; i < prop_samples; i++) + { + // Random direction and distance for this sample + float fi = float(i); + vec2 dir = sampleDirection(fi, uv); + float dist = (hash(uv + fi * 3.77) * 0.75 + 0.25) * radius; + + vec2 sampleUV = uv + dir * dist; + float sampleH = texture(height, sampleUV).r * prop_height_scale; + + // Height difference + float dh = sampleH - centerH; + + // How much this sample occludes: higher neighbors block light + // Scale by inverse distance so closer samples matter more + float distFactor = 1.0 - (dist / radius); + float contribution = max(dh - prop_bias, 0.0) * distFactor; + + occlusion += contribution; + } + + occlusion = occlusion / sampleCount; + occlusion = 1.0 - clamp(occlusion * prop_intensity, 0.0, 1.0); + + return vec4(vec3(occlusion), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/models.cpp b/src/texturelab/models.cpp index b709fdc2..a81b6bb4 100644 --- a/src/texturelab/models.cpp +++ b/src/texturelab/models.cpp @@ -118,7 +118,7 @@ TextureProjectPtr TextureProject::createEmpty(Library* library) if (library != nullptr) project->library = library; else - project->library = createLibraryV2(); + project->library = createLibraryV3(); return TextureProjectPtr(project); } diff --git a/src/texturelab/project.cpp b/src/texturelab/project.cpp index 18501eeb..3e01f78a 100644 --- a/src/texturelab/project.cpp +++ b/src/texturelab/project.cpp @@ -26,7 +26,7 @@ TextureProjectPtr Project::loadTexture(QString path) // create library from version // Library *lib = new LibraryV1(); - Library* lib = createLibraryV2(); + Library* lib = createLibraryV3(); // scene objects auto sceneObj = json["scene"].toObject(); From e494445fa2483f0a66901c9a951caac2c3391073 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 25 Mar 2026 15:26:35 -0500 Subject: [PATCH 003/164] fix random generation and default prop values --- .../libraries/v3/ambientocclusion.cpp | 29 +++++-------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/src/texturelab/libraries/v3/ambientocclusion.cpp b/src/texturelab/libraries/v3/ambientocclusion.cpp index a50b3591..7efc94bc 100644 --- a/src/texturelab/libraries/v3/ambientocclusion.cpp +++ b/src/texturelab/libraries/v3/ambientocclusion.cpp @@ -8,26 +8,12 @@ void AmbientOcclusionNode::init() this->addInput("height"); this->addFloatProp("radius", "Radius", 0.05, 0.001, 1.0, 0.005); - this->addIntProp("samples", "Samples", 16, 4, 64, 4); + this->addIntProp("samples", "Samples", 64, 4, 64, 4); this->addFloatProp("intensity", "Intensity", 1.0, 0.1, 5.0, 0.1); this->addFloatProp("bias", "Bias", 0.01, 0.0, 0.1, 0.005); - this->addFloatProp("height_scale", "Height Scale", 0.1, 0.01, 1.0, 0.01); + this->addFloatProp("height_scale", "Height Scale", 1.0, 0.01, 1.0, 0.01); auto source = R""""( - // Hash function for pseudo-random sampling directions - float hash(vec2 p) - { - vec3 p3 = fract(vec3(p.xyx) * 0.1031); - p3 += dot(p3, p3.yzx + 33.33); - return fract((p3.x + p3.y) * p3.z); - } - - vec2 sampleDirection(float i, vec2 uv) - { - float angle = hash(uv + i * 7.13) * 6.28318530718; - return vec2(cos(angle), sin(angle)); - } - vec4 process(vec2 uv) { float centerH = texture(height, uv).r * prop_height_scale; @@ -37,18 +23,17 @@ void AmbientOcclusionNode::init() for (int i = 0; i < prop_samples; i++) { - // Random direction and distance for this sample - float fi = float(i); - vec2 dir = sampleDirection(fi, uv); - float dist = (hash(uv + fi * 3.77) * 0.75 + 0.25) * radius; + // randomFloat(index) uses _randomStart (per-pixel) + _seed + index + float angle = randomFloat(i * 2) * 6.28318530718; + vec2 dir = vec2(cos(angle), sin(angle)); + float dist = (randomFloat(i * 2 + 1) * 0.75 + 0.25) * radius; vec2 sampleUV = uv + dir * dist; float sampleH = texture(height, sampleUV).r * prop_height_scale; - // Height difference + // Height difference — higher neighbors block light float dh = sampleH - centerH; - // How much this sample occludes: higher neighbors block light // Scale by inverse distance so closer samples matter more float distFactor = 1.0 - (dist / radius); float contribution = max(dh - prop_bias, 0.0) * distFactor; From ffe75a688a669ff8ac4847712dc126772162cb01 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 25 Mar 2026 16:01:11 -0500 Subject: [PATCH 004/164] add curvature node --- src/texturelab/CMakeLists.txt | 1 + src/texturelab/libraries/library.cpp | 2 + src/texturelab/libraries/libv3.h | 5 ++ src/texturelab/libraries/v3/curvature.cpp | 97 +++++++++++++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 src/texturelab/libraries/v3/curvature.cpp diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 5b1d5b90..a6cd3bb9 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -100,6 +100,7 @@ set(LIBRARYV2 set(LIBRARYV3 ./libraries/v3/ambientocclusion.cpp + ./libraries/v3/curvature.cpp ) set(PROJECT_SOURCES diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index 4b65894c..2fe4d068 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -192,6 +192,8 @@ Library* createLibraryV3() // V3 NODES lib->addNode("ambientocclusion", "Ambient Occlusion", ":nodes/bevel.png"); + lib->addNode("curvature", "Curvature", + ":nodes/bevel.png"); return lib; } \ No newline at end of file diff --git a/src/texturelab/libraries/libv3.h b/src/texturelab/libraries/libv3.h index 3dddbfc9..020ec31d 100644 --- a/src/texturelab/libraries/libv3.h +++ b/src/texturelab/libraries/libv3.h @@ -6,3 +6,8 @@ class AmbientOcclusionNode : public TextureNode { public: virtual void init() override; }; + +class CurvatureNode : public TextureNode { +public: + virtual void init() override; +}; diff --git a/src/texturelab/libraries/v3/curvature.cpp b/src/texturelab/libraries/v3/curvature.cpp new file mode 100644 index 00000000..d6b3cc1a --- /dev/null +++ b/src/texturelab/libraries/v3/curvature.cpp @@ -0,0 +1,97 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void CurvatureNode::init() +{ + this->title = "Curvature"; + this->addInput("height"); + + auto typeProp = this->addEnumProp("type", "Type", + {"Mix", "Sharp", "Medium", "Smooth"}); + typeProp->index = 3; + + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 2.0, 0.1); + this->addFloatProp("angle", "Angle", 0.5, -1.0, 1.0, 0.1); + this->addIntProp("samples", "Samples", 12, 1, 32, 1); + + auto advancedProps = this->createGroup("Advanced"); + advancedProps->add( + this->addFloatProp("sh_c", "Sharp Curvature", 0.5, 0.0, 1.0, 0.1)); + advancedProps->add( + this->addFloatProp("me_c", "Medium Curvature", 1.5, 0.0, 3.0, 0.1)); + advancedProps->add( + this->addFloatProp("sm_c", "Smooth Curvature", 3.0, 0.0, 4.0, 0.1)); + + auto source = R""""( + #define LAPLACIAN_CENTER_WEIGHT 2.0 + #define RADIUS_SCALE 0.01 + #define SHARP_WEIGHT 8.0 + #define MEDIUM_WEIGHT 3.0 + #define SMOOTH_WEIGHT 1.5 + + #define TYPE_MIX 0 + #define TYPE_SHARP 1 + #define TYPE_MEDIUM 2 + #define TYPE_SMOOTH 3 + + float HeightMap(vec2 p) + { + return texture(height, p).x; + } + + float Curve(vec2 p, vec2 o) + { + float a = HeightMap(p + o); + float b = HeightMap(p - o); + return -a - b; + } + + float CurvatureMap(vec2 p, float r) + { + float q = float(prop_samples); + float s = r / q; + float H = HeightMap(p) * LAPLACIAN_CENTER_WEIGHT; + float v = 0.0; + + for (float ox = -q; ox < q; ox++) + for (float oy = -q; oy < q; oy++) + { + vec2 o = vec2(ox, oy); + float c = Curve(p, o * s); + v += (H + c) * ((r - length(o * s)) / r); + } + + return v / (q * q); + } + + vec4 process(vec2 uv) + { + if (!height_connected) + return vec4(0.0, 0.0, 0.0, 1.0); + + float i = prop_intensity; + float c = 0.0; + + if (prop_type == TYPE_MIX) { + c += CurvatureMap(uv, i * prop_sh_c * RADIUS_SCALE) * SHARP_WEIGHT; + c += CurvatureMap(uv, i * prop_me_c * RADIUS_SCALE) * MEDIUM_WEIGHT; + c += CurvatureMap(uv, i * prop_sm_c * RADIUS_SCALE) * SMOOTH_WEIGHT; + } else if (prop_type == TYPE_SHARP) { + c += CurvatureMap(uv, i * prop_sh_c * RADIUS_SCALE) * SHARP_WEIGHT; + } else if (prop_type == TYPE_MEDIUM) { + c += CurvatureMap(uv, i * prop_me_c * RADIUS_SCALE) * MEDIUM_WEIGHT; + } else if (prop_type == TYPE_SMOOTH) { + c += CurvatureMap(uv, i * prop_sm_c * RADIUS_SCALE) * SMOOTH_WEIGHT; + } + + vec4 color; + color.rgb = vec3(prop_angle + c); + color.a = 1.0; + + return color; + } + )""""; + + this->setShaderSource(source); +} From 05f4d3105fa416f40b3d7ceefa17ac51c7e2ae6e Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 25 Mar 2026 16:17:33 -0500 Subject: [PATCH 005/164] add swirl, rays and masked blur nodes --- src/texturelab/CMakeLists.txt | 3 ++ src/texturelab/libraries/library.cpp | 4 ++ src/texturelab/libraries/libv3.h | 15 ++++++ src/texturelab/libraries/v3/maskedblur.cpp | 62 ++++++++++++++++++++++ src/texturelab/libraries/v3/rays.cpp | 29 ++++++++++ src/texturelab/libraries/v3/swirl.cpp | 36 +++++++++++++ 6 files changed, 149 insertions(+) create mode 100644 src/texturelab/libraries/v3/maskedblur.cpp create mode 100644 src/texturelab/libraries/v3/rays.cpp create mode 100644 src/texturelab/libraries/v3/swirl.cpp diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index a6cd3bb9..7b6e6edb 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -101,6 +101,9 @@ set(LIBRARYV2 set(LIBRARYV3 ./libraries/v3/ambientocclusion.cpp ./libraries/v3/curvature.cpp + ./libraries/v3/maskedblur.cpp + ./libraries/v3/rays.cpp + ./libraries/v3/swirl.cpp ) set(PROJECT_SOURCES diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index 2fe4d068..fb3d2347 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -194,6 +194,10 @@ Library* createLibraryV3() ":nodes/bevel.png"); lib->addNode("curvature", "Curvature", ":nodes/bevel.png"); + lib->addNode("maskedblur", "Masked Blur", + ":nodes/blurv2.png"); + lib->addNode("rays", "Rays", ":nodes/bevel.png"); + lib->addNode("swirl", "Swirl", ":nodes/bevel.png"); return lib; } \ No newline at end of file diff --git a/src/texturelab/libraries/libv3.h b/src/texturelab/libraries/libv3.h index 020ec31d..240c8999 100644 --- a/src/texturelab/libraries/libv3.h +++ b/src/texturelab/libraries/libv3.h @@ -11,3 +11,18 @@ class CurvatureNode : public TextureNode { public: virtual void init() override; }; + +class MaskedBlurNode : public TextureNode { +public: + virtual void init() override; +}; + +class RaysNode : public TextureNode { +public: + virtual void init() override; +}; + +class SwirlNode : public TextureNode { +public: + virtual void init() override; +}; diff --git a/src/texturelab/libraries/v3/maskedblur.cpp b/src/texturelab/libraries/v3/maskedblur.cpp new file mode 100644 index 00000000..d1f20c7d --- /dev/null +++ b/src/texturelab/libraries/v3/maskedblur.cpp @@ -0,0 +1,62 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void MaskedBlurNode::init() +{ + this->title = "Masked Blur"; + this->addInput("image"); + this->addInput("mask"); + + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 10.0, 0.1); + this->addIntProp("samples", "Samples", 50, 1, 100, 1); + this->addFloatProp("opacity", "Opacity", 0.5, 0.0, 1.0, 0.01); + + auto source = R""""( + #define PI 3.14159265359 + #define POW2(x) ((x) * (x)) + + float gaussian(vec2 i, float sigma) + { + return 1.0 / (2.0 * PI * POW2(sigma)) + * exp(-(POW2(i.x) + POW2(i.y)) / (2.0 * POW2(sigma))); + } + + vec3 blur(sampler2D sp, vec2 uv, vec2 scale) + { + vec3 col = vec3(0.0); + float accum = 0.0; + float sigma = float(prop_samples) * 0.25; + int half_samples = prop_samples / 2; + + for (int x = -half_samples; x < half_samples; x++) + for (int y = -half_samples; y < half_samples; y++) + { + vec2 offset = vec2(float(x), float(y)); + float weight = gaussian(offset, sigma); + col += texture(sp, uv + scale * offset).rgb * weight; + accum += weight; + } + + return col / accum; + } + + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.0, 0.0, 0.0, 1.0); + + float maskVal = mask_connected ? 1.0 - texture(mask, uv).r : 1.0; + float blurAmount = 1.0 - maskVal * prop_opacity; + + vec2 ps = vec2(1.0) / _textureSize; + vec4 color; + color.rgb = blur(image, uv, ps * prop_intensity * blurAmount); + color.a = 1.0; + + return color; + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/rays.cpp b/src/texturelab/libraries/v3/rays.cpp new file mode 100644 index 00000000..11a90370 --- /dev/null +++ b/src/texturelab/libraries/v3/rays.cpp @@ -0,0 +1,29 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void RaysNode::init() +{ + this->title = "Rays"; + + this->addIntProp("sides", "Sides", 3, 1, 32, 1); + this->addFloatProp("angle", "Angle", 0.0, 0.0, 360.0, 1.0); + this->addFloatProp("translateX", "Translate X", 0.5, 0.0, 1.0, 0.01); + this->addFloatProp("translateY", "Translate Y", 0.5, 0.0, 1.0, 0.01); + + auto source = R""""( + #define PI 3.14159265359 + + vec4 process(vec2 uv) + { + uv -= vec2(prop_translateX, prop_translateY); + + float a = atan(uv.x, uv.y) + radians(prop_angle); + float shape = sin(a * float(prop_sides)); + + return vec4(vec3(shape), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/swirl.cpp b/src/texturelab/libraries/v3/swirl.cpp new file mode 100644 index 00000000..e66c4486 --- /dev/null +++ b/src/texturelab/libraries/v3/swirl.cpp @@ -0,0 +1,36 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void SwirlNode::init() +{ + this->title = "Swirl"; + this->addInput("image"); + + this->addFloatProp("radius", "Radius", 0.7, 0.0, 1.0, 0.01); + this->addFloatProp("angle", "Angle", 90.0, 0.0, 360.0, 0.1); + this->addFloatProp("centerX", "Center X", 0.5, 0.0, 1.0, 0.01); + this->addFloatProp("centerY", "Center Y", 0.5, 0.0, 1.0, 0.01); + + auto source = R""""( + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.0, 0.0, 0.0, 1.0); + + vec2 center = vec2(prop_centerX, prop_centerY); + vec2 delta = uv - center; + float len = length(delta); + float effectAngle = radians(prop_angle); + + float swirlAmount = effectAngle * smoothstep(prop_radius, 0.0, len); + float a = atan(delta.y, delta.x) + swirlAmount; + + vec2 swirlUV = center + vec2(cos(a), sin(a)) * len; + + return texture(image, swirlUV); + } + )""""; + + this->setShaderSource(source); +} From 7c90466851df34f25b6ce4afd449e61708b67891 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 25 Mar 2026 20:22:54 -0500 Subject: [PATCH 006/164] add basis for bevel node --- src/texturelab/CMakeLists.txt | 2 + src/texturelab/graphics/noderenderer.cpp | 408 ++++++++++++++++++++ src/texturelab/graphics/noderenderer.h | 124 ++++++ src/texturelab/graphics/renderworker.cpp | 111 +++--- src/texturelab/graphics/renderworker.h | 10 + src/texturelab/graphics/texturerenderer.cpp | 6 + src/texturelab/libraries/libv2.h | 3 +- src/texturelab/libraries/v2/bevel.cpp | 396 ++++++++++--------- src/texturelab/models.h | 16 + 9 files changed, 839 insertions(+), 237 deletions(-) create mode 100644 src/texturelab/graphics/noderenderer.cpp create mode 100644 src/texturelab/graphics/noderenderer.h diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 7b6e6edb..1ffd99b2 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -141,6 +141,8 @@ set(PROJECT_SOURCES ./widgets/exportdialog.cpp ./graphics/texturerenderer.h ./graphics/texturerenderer.cpp + ./graphics/noderenderer.h + ./graphics/noderenderer.cpp ./graphics/renderworker.h ./graphics/renderworker.cpp ${LIBRARYV1} diff --git a/src/texturelab/graphics/noderenderer.cpp b/src/texturelab/graphics/noderenderer.cpp new file mode 100644 index 00000000..7eb23274 --- /dev/null +++ b/src/texturelab/graphics/noderenderer.cpp @@ -0,0 +1,408 @@ +#include "noderenderer.h" +#include "../props.h" + +#include +#include +#include +#include +#include + +// ============================================================================ +// RenderResourceCache +// ============================================================================ + +void RenderResourceCache::init(QOpenGLFunctions_3_2_Core* glFuncs, GLuint fboId) +{ + gl = glFuncs; + m_fboId = fboId; +} + +void RenderResourceCache::cleanup() +{ + if (!gl) + return; + + for (auto& tex : texturePool) { + if (tex.id != 0) + gl->glDeleteTextures(1, &tex.id); + } + texturePool.clear(); + + qDeleteAll(shaderCache); + shaderCache.clear(); +} + +GLuint RenderResourceCache::acquireTexture(int width, int height) +{ + // Reuse an existing free texture of matching size + for (auto& tex : texturePool) { + if (!tex.inUse && tex.width == width && tex.height == height) { + tex.inUse = true; + return tex.id; + } + } + + // Create new texture + GLuint texId; + gl->glGenTextures(1, &texId); + gl->glBindTexture(GL_TEXTURE_2D, texId); + gl->glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, width, height, 0, GL_RGBA, + GL_FLOAT, nullptr); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + gl->glBindTexture(GL_TEXTURE_2D, 0); + + CachedTexture cached; + cached.id = texId; + cached.width = width; + cached.height = height; + cached.inUse = true; + texturePool.append(cached); + + return texId; +} + +void RenderResourceCache::releaseTexture(GLuint textureId) +{ + for (auto& tex : texturePool) { + if (tex.id == textureId) { + tex.inUse = false; + return; + } + } +} + +void RenderResourceCache::releaseAllTextures() +{ + for (auto& tex : texturePool) { + tex.inUse = false; + } +} + +GLuint RenderResourceCache::getOrCompileShader(const QString& key, + const QString& vertexSource, + const QString& fragmentSource) +{ + if (shaderCache.contains(key)) + return shaderCache[key]->programId(); + + auto program = new QOpenGLShaderProgram(); + + if (!program->addShaderFromSourceCode(QOpenGLShader::Vertex, vertexSource)) { + qDebug() << "NodeRenderer: Vertex shader error [" << key << "]"; + qDebug() << program->log(); + } + + if (!program->addShaderFromSourceCode(QOpenGLShader::Fragment, + fragmentSource)) { + qDebug() << "NodeRenderer: Fragment shader error [" << key << "]"; + qDebug() << program->log(); + } + + // Bind attribute locations matching the worker's VBO layout + program->bindAttributeLocation("a_pos", 0); // VertexUsage::Position + program->bindAttributeLocation("a_color", 1); // VertexUsage::Color + program->bindAttributeLocation("a_texCoord", 2); // VertexUsage::TexCoord0 + + if (!program->link()) { + qDebug() << "NodeRenderer: Shader link error [" << key << "]"; + qDebug() << program->log(); + } + + shaderCache[key] = program; + return program->programId(); +} + +GLuint RenderResourceCache::compileNodeShader( + const QString& key, const QString& processSource, + const QStringList& inputNames, + const QList>& propTypes) +{ + if (shaderCache.contains(key)) + return shaderCache[key]->programId(); + + QString fSource = fragmentPreamble() + randomLib() + gradientLib() + + generateInputDeclarations(inputNames) + + generatePropDeclarations(propTypes) + "#line 0\n" + + processSource; + + return getOrCompileShader(key, standardVertexSource(), fSource); +} + +void RenderResourceCache::bindFboToTexture(GLuint textureId) +{ + gl->glBindFramebuffer(GL_FRAMEBUFFER, m_fboId); + gl->glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, textureId, 0); +} + +// ============================================================================ +// Static shader source helpers +// ============================================================================ + +QString RenderResourceCache::standardVertexSource() +{ + return R""""( + #version 150 core + in vec3 a_pos; + in vec2 a_texCoord; + out vec2 v_texCoord; + void main() { + v_texCoord = a_texCoord; + gl_Position = vec4(a_pos, 1); + } + )""""; +} + +QString RenderResourceCache::fragmentPreamble() +{ + return R""""( + #version 150 core + in vec2 v_texCoord; + + #define GRADIENT_MAX_POINTS 32 + + vec4 process(vec2 uv); + void initRandom(); + + uniform vec2 _textureSize; + + out vec4 fragColor; + + void main() { + initRandom(); + vec4 result = process(v_texCoord); + fragColor = clamp(result, 0.0, 1.0); + } + )""""; +} + +QString RenderResourceCache::randomLib() +{ + // Exact copy of TextureRenderer::createRandomLib() + return R""""( + uniform float _seed; + vec2 _randomStart; + + #define RANDOM_ITERATIONS 1 + + #define HASHSCALE1 443.8975 + #define HASHSCALE3 vec3(443.897, 441.423, 437.195) + #define HASHSCALE4 vec4(443.897, 441.423, 437.195, 444.129) + + float hash12(vec2 p) + { + vec3 p3 = fract(vec3(p.xyx) * HASHSCALE1); + p3 += dot(p3, p3.yzx + 19.19); + return fract((p3.x + p3.y) * p3.z); + } + + vec2 hash22(vec2 p) + { + vec3 p3 = fract(vec3(p.xyx) * HASHSCALE3); + p3 += dot(p3, p3.yzx+19.19); + return fract((p3.xx+p3.yz)*p3.zy); + } + + float _rand(vec2 uv) + { + float a = 0.0; + for (int t = 0; t < RANDOM_ITERATIONS; t++) + { + float v = float(t+1)*.152; + vec2 pos = (uv * v); + a += hash12(pos); + } + return a/float(RANDOM_ITERATIONS); + } + + vec2 _rand2(vec2 uv) + { + vec2 a = vec2(0.0); + for (int t = 0; t < RANDOM_ITERATIONS; t++) + { + float v = float(t+1)*.152; + vec2 pos = (uv * v); + a += hash22(pos); + } + return a/float(RANDOM_ITERATIONS); + } + + float randomFloat(int index) + { + return _rand(_randomStart + vec2(_seed) + vec2(index)); + } + + float randomVec2(int index) + { + return _rand(_randomStart + vec2(_seed) + vec2(index)); + } + + float randomFloat(int index, float start, float end) + { + float r = _rand(_randomStart + vec2(_seed) + vec2(index)); + return start + r*(end-start); + } + + int randomInt(int index, int start, int end) + { + float r = _rand(_randomStart + vec2(_seed) + vec2(index)); + return start + int(r*float(end-start)); + } + + bool randomBool(int index) + { + return _rand(_randomStart + vec2(_seed) + vec2(index)) > 0.5; + } + + void initRandom() + { + _randomStart = v_texCoord; + } + )""""; +} + +QString RenderResourceCache::gradientLib() +{ + // Exact copy of TextureRenderer::createGradientLib() + return R""""( + struct Gradient { + vec3 colors[GRADIENT_MAX_POINTS]; + float positions[GRADIENT_MAX_POINTS]; + int numPoints; + }; + + vec3 sampleGradient(vec3 colors[GRADIENT_MAX_POINTS], + float positions[GRADIENT_MAX_POINTS], + int numPoints, float t) + { + if (numPoints == 0) + return vec3(1,0,0); + + if (numPoints == 1) + return colors[0]; + + if (t <= positions[0]) + return colors[0]; + + int last = numPoints - 1; + if (t >= positions[last]) + return colors[last]; + + for(int i = 0; i < numPoints-1; i++) { + if (positions[i+1] > t) { + vec3 colorA = colors[i]; + vec3 colorB = colors[i+1]; + + float t1 = positions[i]; + float t2 = positions[i+1]; + + float lerpPos = (t - t1)/(t2 - t1); + return mix(colorA, colorB, lerpPos); + } + } + + return vec3(0,0,0); + } + + vec3 sampleGradient(Gradient gradient, float t) + { + return sampleGradient(gradient.colors, gradient.positions, + gradient.numPoints, t); + } + )""""; +} + +QString RenderResourceCache::generateInputDeclarations( + const QStringList& inputNames) +{ + QString code; + for (const auto& input : inputNames) { + code += "uniform sampler2D " + input + ";\n"; + code += "uniform bool " + input + "_connected;\n"; + } + return code; +} + +QString RenderResourceCache::generatePropDeclarations( + const QList>& propTypes) +{ + QString code; + for (const auto& prop : propTypes) { + const QString& name = prop.first; + auto type = static_cast(prop.second); + + switch (type) { + case PropType::Int: + code += "uniform int prop_" + name + ";\n"; + break; + case PropType::Float: + code += "uniform float prop_" + name + ";\n"; + break; + case PropType::Bool: + code += "uniform bool prop_" + name + ";\n"; + break; + case PropType::Enum: + code += "uniform int prop_" + name + ";\n"; + break; + case PropType::Color: + code += "uniform vec4 prop_" + name + ";\n"; + break; + case PropType::Gradient: + code += "uniform Gradient prop_" + name + ";\n"; + break; + case PropType::Image: + code += "uniform sampler2D prop_" + name + ";\n"; + break; + default: + break; + } + } + return code + "\n"; +} + +// ============================================================================ +// NodeRenderContext +// ============================================================================ + +void NodeRenderContext::useShader(GLuint programId) +{ + gl->glUseProgram(programId); + gl->glViewport(0, 0, textureWidth, textureHeight); + gl->glUniform2f(gl->glGetUniformLocation(programId, "_textureSize"), + (float)textureWidth, (float)textureHeight); + gl->glUniform1f(gl->glGetUniformLocation(programId, "_seed"), randomSeed); +} + +void NodeRenderContext::bindTexture(GLuint programId, + const QString& uniformName, + GLuint textureId, int unit) +{ + gl->glActiveTexture(GL_TEXTURE0 + unit); + gl->glBindTexture(GL_TEXTURE_2D, textureId); + gl->glUniform1i( + gl->glGetUniformLocation(programId, + uniformName.toStdString().c_str()), + unit); +} + +void NodeRenderContext::drawQuad() +{ + vao->bind(); + vbo->bind(); + + // a_pos = attribute 0, a_texCoord = attribute 2 + gl->glEnableVertexAttribArray(0); + gl->glEnableVertexAttribArray(2); + gl->glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), + nullptr); + gl->glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), + reinterpret_cast(3 * sizeof(float))); + + gl->glDrawArrays(GL_TRIANGLES, 0, 6); + + vbo->release(); + vao->release(); +} diff --git a/src/texturelab/graphics/noderenderer.h b/src/texturelab/graphics/noderenderer.h new file mode 100644 index 00000000..f558cf9f --- /dev/null +++ b/src/texturelab/graphics/noderenderer.h @@ -0,0 +1,124 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class QOpenGLFunctions_3_2_Core; +class QOpenGLVertexArrayObject; +class QOpenGLBuffer; +class QOpenGLShaderProgram; + +// Input texture binding passed to custom renderers +struct NodeInputBinding { + QString name; + GLuint textureId; +}; + +// Base class for node-specific render data. +// Nodes subclass this to carry parameters to the render thread. +// Must not reference GPU resources — only plain values. +struct NodeRenderData { + virtual ~NodeRenderData() = default; +}; + +// Worker-owned GPU resource pool. +// Manages intermediate textures and compiled shaders. +// All methods must be called on the render thread with GL context active. +class RenderResourceCache { +public: + RenderResourceCache() = default; + + void init(QOpenGLFunctions_3_2_Core* glFuncs, GLuint fboId); + void cleanup(); + + // --- Intermediate textures --- + GLuint acquireTexture(int width, int height); + void releaseTexture(GLuint textureId); + void releaseAllTextures(); + + // --- Shader cache (raw GLSL) --- + GLuint getOrCompileShader(const QString& key, + const QString& vertexSource, + const QString& fragmentSource); + + // --- Shader cache (node-style with standard wrapping) --- + // Wraps processSource with random lib, gradient lib, input/prop declarations. + // propTypes: list of (name, PropType::Value as int) pairs. + GLuint compileNodeShader(const QString& key, + const QString& processSource, + const QStringList& inputNames, + const QList>& propTypes); + + // --- FBO --- + GLuint fboId() const { return m_fboId; } + void bindFboToTexture(GLuint textureId); + + // Standard vertex shader source (shared across all node shaders) + static QString standardVertexSource(); + +private: + QOpenGLFunctions_3_2_Core* gl = nullptr; + GLuint m_fboId = 0; + + struct CachedTexture { + GLuint id = 0; + int width = 0; + int height = 0; + bool inUse = false; + }; + QList texturePool; + + QMap shaderCache; + + static QString fragmentPreamble(); + static QString randomLib(); + static QString gradientLib(); + static QString generateInputDeclarations(const QStringList& inputNames); + static QString generatePropDeclarations( + const QList>& propTypes); +}; + +// View into the worker's GL state, passed to renderers at render time. +// Provides helpers for common rendering operations. +struct NodeRenderContext { + QOpenGLFunctions_3_2_Core* gl; + RenderResourceCache* cache; + + GLuint outputTextureId; + int textureWidth; + int textureHeight; + float randomSeed; + + QList inputs; + + QOpenGLVertexArrayObject* vao; + QOpenGLBuffer* vbo; + + // Bind shader, set viewport, set _textureSize and _seed uniforms + void useShader(GLuint programId); + + // Bind a texture to a sampler uniform + void bindTexture(GLuint programId, const QString& uniformName, + GLuint textureId, int unit); + + // Draw a fullscreen quad using the currently bound shader + void drawQuad(); +}; + +// Abstract base for custom node renderers. +// Subclass this to implement multi-pass or custom rendering logic. +// The worker delegates to render() instead of the standard single-pass path. +class NodeTextureRenderer { +public: + virtual ~NodeTextureRenderer() = default; + + // Called on the render thread with full GL context. + // Must write final result to context.outputTextureId. + virtual void render(NodeRenderContext& context, + const NodeRenderData& data) = 0; +}; diff --git a/src/texturelab/graphics/renderworker.cpp b/src/texturelab/graphics/renderworker.cpp index 38e4bc32..126c1170 100644 --- a/src/texturelab/graphics/renderworker.cpp +++ b/src/texturelab/graphics/renderworker.cpp @@ -205,6 +205,9 @@ void RenderWorker::setup() // gl->glReadBuffer(GL_NONE); gl->glBindFramebuffer(GL_FRAMEBUFFER, 0); + // Initialize resource cache for custom node renderers + resourceCache.init(gl, fboId); + #ifdef __linux__ // setup renderdoc if (void* mod = dlopen("librenderdoc.so", RTLD_NOW | RTLD_NOLOAD)) { @@ -219,20 +222,47 @@ void RenderWorker::setup() void RenderWorker::processRenderCommand(const RenderCommand& command) { - // Here you would bind the shader, set up inputs and props, and render to a - // texture. This is a placeholder implementation. - if (rdoc_api) rdoc_api->StartFrameCapture(NULL, NULL); ctx->makeCurrent(surface); - // Handle CPU processing nodes differently + // Custom renderer path — node defines its own multi-pass rendering + if (command.renderer) { + NodeRenderContext renderCtx; + renderCtx.gl = gl; + renderCtx.cache = &resourceCache; + renderCtx.outputTextureId = command.textureId; + renderCtx.textureWidth = command.textureWidth; + renderCtx.textureHeight = command.textureHeight; + renderCtx.randomSeed = command.randomSeed; + renderCtx.vao = vao; + renderCtx.vbo = vbo; + + for (const auto& input : command.inputs) { + renderCtx.inputs.append( + NodeInputBinding{input.inputName, input.textureId}); + } + + command.renderer->render(renderCtx, *command.renderData); + resourceCache.releaseAllTextures(); + + gl->glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, 0, 0); + gl->glBindFramebuffer(GL_FRAMEBUFFER, ctx->defaultFramebufferObject()); + + ctx->doneCurrent(); + + if (rdoc_api) + rdoc_api->EndFrameCapture(NULL, NULL); + + emit nodeRendered(command.nodeId, command.textureId); + return; + } + + // CPU processing path if (command.usesCpuProcessing && command.nodePtr != nullptr) { - // Cast back to TextureNode and call cpuProcess TextureNode* node = static_cast(command.nodePtr); - - // Call the CPU processing method with the full command node->cpuProcess(gl, command); ctx->doneCurrent(); @@ -244,13 +274,23 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) return; } - // Simulate rendering process - GLuint renderedTextureId = - 0; // Replace with actual texture ID after rendering + // Standard single-pass GPU path + renderSinglePass(command); - // qDebug() << "RenderWorker: Processing render command for node:" - // << command.nodeId; + gl->glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, 0, 0); + gl->glBindFramebuffer(GL_FRAMEBUFFER, ctx->defaultFramebufferObject()); + + ctx->doneCurrent(); + if (rdoc_api) + rdoc_api->EndFrameCapture(NULL, NULL); + + emit nodeRendered(command.nodeId, command.textureId); +} + +void RenderWorker::renderSinglePass(const RenderCommand& command) +{ gl->glBindFramebuffer(GL_FRAMEBUFFER, fboId); gl->glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, command.textureId, 0); @@ -258,9 +298,7 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) GLenum status = gl->glCheckFramebufferStatus(GL_FRAMEBUFFER); if (status != GL_FRAMEBUFFER_COMPLETE) { qFatal("FRAMEBUFFER IS NOT COMPLETE!"); - // qWarning("%s Framebuffer is not complete!", command.nodeId); } - // fbo->bind(); gl->glViewport(0, 0, command.textureWidth, command.textureHeight); @@ -268,8 +306,6 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) gl->glClearDepth(0); gl->glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); - // qDebug() << "RenderWorker: Cleared framebuffer for node:" << - // command.nodeId; vao->bind(); if (command.shaderLinked) { @@ -281,7 +317,6 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) gl->glActiveTexture(GL_TEXTURE0 + texIndex); gl->glBindTexture(GL_TEXTURE_2D, 0); - // gl->glUniform1i(node->shader->uniformLocation(input), 0); gl->glUniform1i( gl->glGetUniformLocation(command.shaderId, input.inputName.toStdString().c_str()), @@ -299,12 +334,9 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) texIndex = 0; for (auto nodeInput : command.inputs) { gl->glActiveTexture(GL_TEXTURE0 + texIndex); - // if (!nodeInput.node->texture->bind()) - // qFatal("could not bind texture"); gl->glBindTexture(GL_TEXTURE_2D, nodeInput.textureId); auto name = nodeInput.inputName; - // gl->glUniform1i(node->shader->uniformLocation(input), 0); gl->glUniform1i(gl->glGetUniformLocation( command.shaderId, name.toStdString().c_str()), texIndex); @@ -312,23 +344,15 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) gl->glUniform1i(gl->glGetUniformLocation(command.shaderId, connectedName.c_str()), 1); - // shader->setUniformValue(name.toStdString().c_str(), texIndex); - // shader->setUniformValue((name + - // "_connected").toStdString().c_str(), - // 1); texIndex++; } // pass seed - // shader->setUniformValue("_seed", (GLfloat)(command.randomSeed)); gl->glUniform1f(gl->glGetUniformLocation(command.shaderId, "_seed"), (GLfloat)(command.randomSeed)); // texture size - // shader->setUniformValue( - // "_textureSize", - // QVector2D(command.textureWidth, command.textureHeight)); gl->glUniform2f( gl->glGetUniformLocation(command.shaderId, "_textureSize"), (GLfloat)(command.textureWidth), (GLfloat)(command.textureHeight)); @@ -337,7 +361,6 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) for (auto prop : command.props) { auto propCString = ("prop_" + prop.propName.toStdString()); auto propName = propCString.c_str(); - // qDebug() << "glsl prop: " << propName; switch (prop.propType) { case PropType::Int: { @@ -375,18 +398,15 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) auto gradientVal = prop.value.value(); auto numPoints = gradientVal.points.size(); - // Set number of gradient points gl->glUniform1i( gl->glGetUniformLocation( command.shaderId, (propCString + ".numPoints").c_str()), numPoints); - // Pass each gradient point (color and position) for (int i = 0; i < numPoints; i++) { const auto& point = gradientVal.points[i]; const auto& color = point.color; - // Set color for this point std::string colorPath = propCString + ".colors[" + std::to_string(i) + "]"; gl->glUniform3f(gl->glGetUniformLocation(command.shaderId, @@ -394,7 +414,6 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) color.redF(), color.greenF(), color.blueF()); - // Set position for this point std::string posPath = propCString + ".positions[" + std::to_string(i) + "]"; gl->glUniform1f(gl->glGetUniformLocation(command.shaderId, @@ -403,7 +422,6 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) } } break; case PropType::Image: { - // Use pre-uploaded texture ID from main thread if (prop.textureId != 0) { gl->glActiveTexture(GL_TEXTURE0 + texIndex); gl->glBindTexture(GL_TEXTURE_2D, prop.textureId); @@ -413,10 +431,8 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) texIndex++; } else { - // No texture provided, bind a default texture (e.g., white) gl->glActiveTexture(GL_TEXTURE0 + texIndex); - gl->glBindTexture(GL_TEXTURE_2D, 0); // Bind to 0 or a - // default texture + gl->glBindTexture(GL_TEXTURE_2D, 0); gl->glUniform1i( gl->glGetUniformLocation(command.shaderId, propName), texIndex); @@ -442,27 +458,6 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) } vao->release(); - - // gl->glBindFramebuffer(GL_FRAMEBUFFER, - // ctx->defaultFramebufferObject()); - - // grab pixels to pixmap - // auto img = node->texture->toImage(); - // img.save(node->id + ".png"); - - // gl->glBindFramebuffer(GL_FRAMEBUFFER, 0); - // fbo->release(); - - gl->glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, - GL_TEXTURE_2D, 0, 0); - gl->glBindFramebuffer(GL_FRAMEBUFFER, ctx->defaultFramebufferObject()); - - ctx->doneCurrent(); - - if (rdoc_api) - rdoc_api->EndFrameCapture(NULL, NULL); - // Emit signal that node has been rendered - emit nodeRendered(command.nodeId, command.textureId); } void RenderWorker::kill() { running = false; } diff --git a/src/texturelab/graphics/renderworker.h b/src/texturelab/graphics/renderworker.h index 94cd957c..3982bd11 100644 --- a/src/texturelab/graphics/renderworker.h +++ b/src/texturelab/graphics/renderworker.h @@ -1,12 +1,14 @@ #pragma once #include "../props.h" +#include "noderenderer.h" #include #include #include #include #include #include +#include class QOffscreenSurface; class QOpenGLContext; @@ -53,6 +55,10 @@ struct RenderCommand { // props QList props; + + // Custom rendering support (null = standard single-pass) + std::shared_ptr renderer; + std::shared_ptr renderData; }; class RenderWorker : public QObject { @@ -70,10 +76,14 @@ class RenderWorker : public QObject { // use custom dbo that gets shared across render textures GLuint fboId; + RenderResourceCache resourceCache; + QMutex mutex; std::atomic running; QQueue renderQueue; + void renderSinglePass(const RenderCommand& command); + public: RenderWorker(); diff --git a/src/texturelab/graphics/texturerenderer.cpp b/src/texturelab/graphics/texturerenderer.cpp index 63a7d8e7..1bffb3df 100644 --- a/src/texturelab/graphics/texturerenderer.cpp +++ b/src/texturelab/graphics/texturerenderer.cpp @@ -617,6 +617,12 @@ void TextureRenderer::queueNextNodeToRender() cmd.shaderLinked = nextNode->shader->isLinked(); cmd.randomSeed = project->randomSeed + nextNode->randomSeed; + // Custom renderer support + cmd.renderer = nextNode->createRenderer(); + if (cmd.renderer) { + cmd.renderData = nextNode->createRenderData(); + } + // CPU processing support cmd.usesCpuProcessing = nextNode->usesCpuProcessing; cmd.nodePtr = nextNode.data(); // Store raw pointer for CPU processing diff --git a/src/texturelab/libraries/libv2.h b/src/texturelab/libraries/libv2.h index f4fc682b..2f11f315 100644 --- a/src/texturelab/libraries/libv2.h +++ b/src/texturelab/libraries/libv2.h @@ -15,7 +15,8 @@ class AnisotropicBlurNode : public TextureNode { class BevelNode : public TextureNode { public: virtual void init() override; - void cpuProcess(void* gl, const RenderCommand& command) override; + std::shared_ptr createRenderer() override; + std::shared_ptr createRenderData() override; }; class BlurNodeV2 : public TextureNode { diff --git a/src/texturelab/libraries/v2/bevel.cpp b/src/texturelab/libraries/v2/bevel.cpp index e3874940..4681f847 100644 --- a/src/texturelab/libraries/v2/bevel.cpp +++ b/src/texturelab/libraries/v2/bevel.cpp @@ -1,208 +1,248 @@ -#include "../../graphics/renderworker.h" +#include "../../graphics/noderenderer.h" #include "../../models.h" #include "../../props.h" #include "../libv2.h" -#include + #include #include #include -#include - -// Constants for Euclidean Distance Transform -static const double INF = 1e20; -// Use normalized float range [0.0, 1.0] for GL_RGBA32F textures -static const float VALUE_MAX = 1.0f; - -// Forward declarations for EDT functions -static void edt(std::vector& data, int width, int height, - std::vector& f, std::vector& v, - std::vector& z); - -static void edt1d(std::vector& grid, int offset, int stride, int length, - std::vector& f, std::vector& v, - std::vector& z); - -void BevelNode::init() -{ - this->title = "Bevel"; - this->addInput("image"); - this->addFloatProp("distance", "Distance", 50.0, 0.0, 100.0, 0.01); - // This node uses CPU processing instead of GPU shader - this->usesCpuProcessing = true; +// ============================================================================ +// BevelRenderData — parameters passed to the render thread +// ============================================================================ + +struct BevelRenderData : public NodeRenderData { + float distance = 50.0f; + float threshold = 0.5f; +}; + +// ============================================================================ +// BevelRenderer — JFA-based GPU bevel implementation +// ============================================================================ + +class BevelRenderer : public NodeTextureRenderer { +public: + void render(NodeRenderContext& ctx, + const NodeRenderData& baseData) override + { + auto& data = static_cast(baseData); + auto gl = ctx.gl; + auto cache = ctx.cache; + + int w = ctx.textureWidth; + int h = ctx.textureHeight; + + // No input connected — output black + if (ctx.inputs.isEmpty() || ctx.inputs[0].textureId == 0) { + cache->bindFboToTexture(ctx.outputTextureId); + gl->glViewport(0, 0, w, h); + gl->glClearColor(0, 0, 0, 1); + gl->glClear(GL_COLOR_BUFFER_BIT); + return; + } - // Set a passthrough shader initially (not used, but required for - // initialization) - auto source = R""""( - vec4 process(vec2 uv) - { - vec4 col = texture(image, uv); - return col; + // Compile shaders (cached after first call) + GLuint seedShader = cache->getOrCompileShader( + "jfa_seed", standardVert(), seedFrag()); + GLuint jfaShader = cache->getOrCompileShader( + "jfa_step", standardVert(), jfaFrag()); + GLuint bevelShader = cache->getOrCompileShader( + "jfa_bevel", standardVert(), bevelFrag()); + + // Acquire two intermediate textures for ping-pong + GLuint texA = cache->acquireTexture(w, h); + GLuint texB = cache->acquireTexture(w, h); + + // --- Pass 0: Seed initialization --- + // Detect edges where the input crosses the threshold + cache->bindFboToTexture(texA); + ctx.useShader(seedShader); + ctx.bindTexture(seedShader, "image", ctx.inputs[0].textureId, 0); + gl->glUniform1f( + gl->glGetUniformLocation(seedShader, "u_threshold"), + data.threshold); + ctx.drawQuad(); + + // --- Passes 1..N: JFA iteration (ping-pong) --- + int maxDim = std::max(w, h); + int stepSize = maxDim / 2; + + while (stepSize >= 1) { + cache->bindFboToTexture(texB); + ctx.useShader(jfaShader); + ctx.bindTexture(jfaShader, "u_input", texA, 0); + gl->glUniform1i( + gl->glGetUniformLocation(jfaShader, "u_stepSize"), stepSize); + ctx.drawQuad(); + + std::swap(texA, texB); + stepSize /= 2; } - )""""; - this->setShaderSource(source); -} -void BevelNode::cpuProcess(void* glPtr, const RenderCommand& command) -{ - // Cast to QOpenGLFunctions_3_2_Core - auto gl = static_cast(glPtr); + // --- Final pass: Distance field → bevel height --- + cache->bindFboToTexture(ctx.outputTextureId); + ctx.useShader(bevelShader); + ctx.bindTexture(bevelShader, "u_jfa", texA, 0); + gl->glUniform1f( + gl->glGetUniformLocation(bevelShader, "u_distance"), + data.distance); + ctx.drawQuad(); - // Get the first input texture if available - GLuint inputTextureId = 0; - if (!command.inputs.isEmpty()) { - inputTextureId = command.inputs[0].textureId; + // Intermediates released by worker after render() returns } - if (inputTextureId == 0) - return; - - int width = command.textureWidth; - int height = command.textureHeight; - - // Allocate buffers - int gridSize = width * height; - std::vector readPixels(gridSize * 4); - std::vector resultPixels(gridSize * 4); - - // Read pixels from input texture - GLuint fbo; - gl->glGenFramebuffers(1, &fbo); - gl->glBindFramebuffer(GL_FRAMEBUFFER, fbo); - gl->glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, - GL_TEXTURE_2D, inputTextureId, 0); - - if (gl->glCheckFramebufferStatus(GL_FRAMEBUFFER) == - GL_FRAMEBUFFER_COMPLETE) { - gl->glReadPixels(0, 0, width, height, GL_RGBA, GL_FLOAT, - readPixels.data()); +private: + static QString standardVert() + { + return RenderResourceCache::standardVertexSource(); } - gl->glBindFramebuffer(GL_FRAMEBUFFER, 0); - gl->glDeleteFramebuffers(1, &fbo); - - // Allocate working arrays - int maxSize = std::max(width, height); - std::vector f(maxSize * 3); - std::vector z(maxSize * 3 + 1); - std::vector v(maxSize * 3); - - std::vector gridOuter(gridSize); - std::vector gridInner(gridSize); - std::vector grid(gridSize); - - // Convert pixels to distance fields - for (int i = 0; i < gridSize; i++) { - float a = readPixels[i * 4 + 0]; // Use red channel - - gridOuter[i] = (a == 1.0f) ? 0.0 - : (a == 0.0f) ? INF - : std::pow(std::max(0.0f, 0.5f - a), 2); - gridInner[i] = (a == 1.0f) ? INF - : (a == 0.0f) ? 0.0 - : std::pow(std::max(0.0f, a - 0.5f), 2); + static QString seedFrag() + { + return R""""( + #version 150 core + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D image; + uniform vec2 _textureSize; + uniform float u_threshold; + + void main() { + vec2 uv = v_texCoord; + float v = texture(image, uv).r; + vec2 texel = vec2(1.0) / _textureSize; + + // Check 4-neighbors for threshold crossing (edge detection) + float n = texture(image, uv + vec2(0.0, texel.y)).r; + float s = texture(image, uv - vec2(0.0, texel.y)).r; + float e = texture(image, uv + vec2(texel.x, 0.0)).r; + float w = texture(image, uv - vec2(texel.x, 0.0)).r; + + bool isEdge = (v >= u_threshold) != (n >= u_threshold) || + (v >= u_threshold) != (s >= u_threshold) || + (v >= u_threshold) != (e >= u_threshold) || + (v >= u_threshold) != (w >= u_threshold); + + if (isEdge) + fragColor = vec4(uv, v, 1.0); // Seed: store own UV + else + fragColor = vec4(-1.0, -1.0, v, 0.0); // No seed + } + )""""; } - // Apply Euclidean Distance Transform - edt(gridOuter, width, height, f, v, z); - edt(gridInner, width, height, f, v, z); - - // Get distance property from RenderCommand props - float radius = 50.0f; - for (const auto& prop : command.props) { - if (prop.propName == "distance" && prop.propType == PropType::Float) { - radius = prop.value.toFloat(); - break; - } + static QString jfaFrag() + { + return R""""( + #version 150 core + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_input; + uniform vec2 _textureSize; + uniform int u_stepSize; + + void main() { + vec2 uv = v_texCoord; + vec2 texel = vec2(1.0) / _textureSize; + vec4 best = texture(u_input, uv); + float bestDist = (best.a < 0.5) ? 9999.0 : length(uv - best.xy); + + // Check 3x3 neighborhood at current step size + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + if (x == 0 && y == 0) continue; + + vec2 offset = vec2(float(x), float(y)) + * float(u_stepSize) * texel; + vec4 neighbor = texture(u_input, uv + offset); + + if (neighbor.a < 0.5) continue; // No seed + + float d = length(uv - neighbor.xy); + if (d < bestDist) { + bestDist = d; + best = neighbor; + } + } + } + + fragColor = best; + } + )""""; } - float offset = 0.25f; - - // Calculate bevel - float minVal = 1.0f; - float maxVal = 0.0f; - for (int i = 0; i < gridSize; i++) { - double d = std::sqrt(gridOuter[i]) - std::sqrt(gridInner[i]); - float col = VALUE_MAX - VALUE_MAX * (d / radius + offset); - col = std::max(0.0f, std::min(VALUE_MAX, col)); - - minVal = std::min(minVal, col); - maxVal = std::max(maxVal, col); - grid[i] = col; + static QString bevelFrag() + { + return R""""( + #version 150 core + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_jfa; + uniform vec2 _textureSize; + uniform float u_distance; + + void main() { + vec2 uv = v_texCoord; + vec4 data = texture(u_jfa, uv); + + if (data.a < 0.5) { + // No nearest seed found + fragColor = vec4(0.0, 0.0, 0.0, 1.0); + return; + } + + // Convert UV-space distance to pixel distance + float dist = length(uv - data.xy) + * max(_textureSize.x, _textureSize.y); + + float bevel = 1.0 - clamp(dist / u_distance, 0.0, 1.0); + fragColor = vec4(vec3(bevel), 1.0); + } + )""""; } +}; - // Normalize and invert - float range = maxVal - minVal; - float scale = (range > 0.0f) ? (1.0f / range) : 1.0f; - - for (int i = 0; i < gridSize; i++) { - float col = 1.0f - (grid[i] - minVal) * scale; // de-invert +// ============================================================================ +// BevelNode +// ============================================================================ - resultPixels[i * 4 + 0] = col; - resultPixels[i * 4 + 1] = col; - resultPixels[i * 4 + 2] = col; - resultPixels[i * 4 + 3] = 1.0f; - } +void BevelNode::init() +{ + this->title = "Bevel"; + this->addInput("image"); + this->addFloatProp("distance", "Distance", 50.0, 0.0, 200.0, 0.5); + this->addFloatProp("threshold", "Threshold", 0.5, 0.0, 1.0, 0.01); - // Upload result to texture - gl->glBindTexture(GL_TEXTURE_2D, command.textureId); - gl->glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, width, height, 0, GL_RGBA, - GL_FLOAT, resultPixels.data()); - gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); - gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); - gl->glBindTexture(GL_TEXTURE_2D, 0); + // Passthrough shader for initialization (not used during rendering — + // custom renderer handles all passes) + auto source = R""""( + vec4 process(vec2 uv) + { + return texture(image, uv); + } + )""""; + this->setShaderSource(source); } -// 2D Euclidean squared distance transform by Felzenszwalb & Huttenlocher -// https://cs.brown.edu/~pff/papers/dt-final.pdf -static void edt(std::vector& data, int width, int height, - std::vector& f, std::vector& v, - std::vector& z) +std::shared_ptr BevelNode::createRenderer() { - for (int x = 0; x < width; x++) - edt1d(data, x, width, height, f, v, z); - for (int y = 0; y < height; y++) - edt1d(data, y * width, 1, width, f, v, z); + return std::make_shared(); } -// 1D squared distance transform -static void edt1d(std::vector& grid, int offset, int stride, int length, - std::vector& f, std::vector& v, - std::vector& z) +std::shared_ptr BevelNode::createRenderData() { - v[0] = 0; - z[0] = -INF; - z[1] = INF; - - // Load line in array three times for wrapping - for (int q = 0; q < length; q++) - f[q] = grid[offset + q * stride]; - for (int q = 0; q < length; q++) - f[q + length] = grid[offset + q * stride]; - for (int q = 0; q < length; q++) - f[q + length + length] = grid[offset + q * stride]; - - int k = 0; - for (int q = 1; q < length * 3; q++) { - double s; - do { - int r = v[k]; - s = (f[q] - f[r] + q * q - r * r) / (q - r) / 2.0; - } while (s <= z[k] && --k > -1); - - k++; - v[k] = q; - z[k] = s; - z[k + 1] = INF; - } + auto data = std::make_shared(); - // Copy over middle section - for (int q = length, k = 0; q < length + length; q++) { - while (z[k + 1] < q) - k++; - int r = v[k]; - grid[offset + (q - length) * stride] = f[r] + (q - r) * (q - r); - } -} \ No newline at end of file + auto distProp = static_cast(this->getProp("distance")); + if (distProp) + data->distance = distProp->value; + + auto threshProp = static_cast(this->getProp("threshold")); + if (threshProp) + data->threshold = threshProp->value; + + return data; +} diff --git a/src/texturelab/models.h b/src/texturelab/models.h index 02833df5..2b181981 100644 --- a/src/texturelab/models.h +++ b/src/texturelab/models.h @@ -9,6 +9,7 @@ #include #include #include +#include class QOpenGLFramebufferObject; class QOpenGLShaderProgram; @@ -29,6 +30,8 @@ typedef QSharedPointer ConnectionPtr; class Prop; class PropertyGroup; class Library; +class NodeTextureRenderer; +struct NodeRenderData; class IntProp; class FloatProp; @@ -143,6 +146,19 @@ class TextureNode : public QEnableSharedFromThis { void setShaderSource(const QString& source) { shaderSource = source; } + // Override to provide a custom renderer for multi-pass or non-standard rendering. + // Called on the main thread during queueNextNodeToRender(). + // Return nullptr for standard single-pass rendering. + virtual std::shared_ptr createRenderer() { + return nullptr; + } + + // Override to provide render-time data for the custom renderer. + // Called on the main thread. Must not reference GPU resources. + virtual std::shared_ptr createRenderData() { + return nullptr; + } + bool isGraphicsResourcesInitialized() { return texture != nullptr && shader != nullptr; From 350a81101cf9c792293eac2149c61730234fc307 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 25 Mar 2026 20:32:48 -0500 Subject: [PATCH 007/164] move new bevel to v3 --- src/texturelab/CMakeLists.txt | 1 + src/texturelab/libraries/library.cpp | 1 + src/texturelab/libraries/libv2.h | 3 +- src/texturelab/libraries/libv3.h | 8 + src/texturelab/libraries/v2/bevel.cpp | 396 +++++++++++------------- src/texturelab/libraries/v3/bevelv2.cpp | 248 +++++++++++++++ 6 files changed, 437 insertions(+), 220 deletions(-) create mode 100644 src/texturelab/libraries/v3/bevelv2.cpp diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 1ffd99b2..5fcb2a53 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -100,6 +100,7 @@ set(LIBRARYV2 set(LIBRARYV3 ./libraries/v3/ambientocclusion.cpp + ./libraries/v3/bevelv2.cpp ./libraries/v3/curvature.cpp ./libraries/v3/maskedblur.cpp ./libraries/v3/rays.cpp diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index fb3d2347..38bf5dda 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -190,6 +190,7 @@ Library* createLibraryV3() auto lib = createLibraryV2(); // V3 NODES + lib->addNode("bevelv2", "Bevel V2", ":nodes/bevel.png"); lib->addNode("ambientocclusion", "Ambient Occlusion", ":nodes/bevel.png"); lib->addNode("curvature", "Curvature", diff --git a/src/texturelab/libraries/libv2.h b/src/texturelab/libraries/libv2.h index 2f11f315..f4fc682b 100644 --- a/src/texturelab/libraries/libv2.h +++ b/src/texturelab/libraries/libv2.h @@ -15,8 +15,7 @@ class AnisotropicBlurNode : public TextureNode { class BevelNode : public TextureNode { public: virtual void init() override; - std::shared_ptr createRenderer() override; - std::shared_ptr createRenderData() override; + void cpuProcess(void* gl, const RenderCommand& command) override; }; class BlurNodeV2 : public TextureNode { diff --git a/src/texturelab/libraries/libv3.h b/src/texturelab/libraries/libv3.h index 240c8999..c33c0ae1 100644 --- a/src/texturelab/libraries/libv3.h +++ b/src/texturelab/libraries/libv3.h @@ -1,6 +1,14 @@ #pragma once #include "../models.h" +#include + +class BevelV2Node : public TextureNode { +public: + virtual void init() override; + std::shared_ptr createRenderer() override; + std::shared_ptr createRenderData() override; +}; class AmbientOcclusionNode : public TextureNode { public: diff --git a/src/texturelab/libraries/v2/bevel.cpp b/src/texturelab/libraries/v2/bevel.cpp index 4681f847..e3874940 100644 --- a/src/texturelab/libraries/v2/bevel.cpp +++ b/src/texturelab/libraries/v2/bevel.cpp @@ -1,248 +1,208 @@ -#include "../../graphics/noderenderer.h" +#include "../../graphics/renderworker.h" #include "../../models.h" #include "../../props.h" #include "../libv2.h" - +#include #include #include #include +#include -// ============================================================================ -// BevelRenderData — parameters passed to the render thread -// ============================================================================ - -struct BevelRenderData : public NodeRenderData { - float distance = 50.0f; - float threshold = 0.5f; -}; - -// ============================================================================ -// BevelRenderer — JFA-based GPU bevel implementation -// ============================================================================ - -class BevelRenderer : public NodeTextureRenderer { -public: - void render(NodeRenderContext& ctx, - const NodeRenderData& baseData) override - { - auto& data = static_cast(baseData); - auto gl = ctx.gl; - auto cache = ctx.cache; - - int w = ctx.textureWidth; - int h = ctx.textureHeight; - - // No input connected — output black - if (ctx.inputs.isEmpty() || ctx.inputs[0].textureId == 0) { - cache->bindFboToTexture(ctx.outputTextureId); - gl->glViewport(0, 0, w, h); - gl->glClearColor(0, 0, 0, 1); - gl->glClear(GL_COLOR_BUFFER_BIT); - return; - } +// Constants for Euclidean Distance Transform +static const double INF = 1e20; +// Use normalized float range [0.0, 1.0] for GL_RGBA32F textures +static const float VALUE_MAX = 1.0f; + +// Forward declarations for EDT functions +static void edt(std::vector& data, int width, int height, + std::vector& f, std::vector& v, + std::vector& z); + +static void edt1d(std::vector& grid, int offset, int stride, int length, + std::vector& f, std::vector& v, + std::vector& z); + +void BevelNode::init() +{ + this->title = "Bevel"; + this->addInput("image"); + this->addFloatProp("distance", "Distance", 50.0, 0.0, 100.0, 0.01); + + // This node uses CPU processing instead of GPU shader + this->usesCpuProcessing = true; - // Compile shaders (cached after first call) - GLuint seedShader = cache->getOrCompileShader( - "jfa_seed", standardVert(), seedFrag()); - GLuint jfaShader = cache->getOrCompileShader( - "jfa_step", standardVert(), jfaFrag()); - GLuint bevelShader = cache->getOrCompileShader( - "jfa_bevel", standardVert(), bevelFrag()); - - // Acquire two intermediate textures for ping-pong - GLuint texA = cache->acquireTexture(w, h); - GLuint texB = cache->acquireTexture(w, h); - - // --- Pass 0: Seed initialization --- - // Detect edges where the input crosses the threshold - cache->bindFboToTexture(texA); - ctx.useShader(seedShader); - ctx.bindTexture(seedShader, "image", ctx.inputs[0].textureId, 0); - gl->glUniform1f( - gl->glGetUniformLocation(seedShader, "u_threshold"), - data.threshold); - ctx.drawQuad(); - - // --- Passes 1..N: JFA iteration (ping-pong) --- - int maxDim = std::max(w, h); - int stepSize = maxDim / 2; - - while (stepSize >= 1) { - cache->bindFboToTexture(texB); - ctx.useShader(jfaShader); - ctx.bindTexture(jfaShader, "u_input", texA, 0); - gl->glUniform1i( - gl->glGetUniformLocation(jfaShader, "u_stepSize"), stepSize); - ctx.drawQuad(); - - std::swap(texA, texB); - stepSize /= 2; + // Set a passthrough shader initially (not used, but required for + // initialization) + auto source = R""""( + vec4 process(vec2 uv) + { + vec4 col = texture(image, uv); + return col; } + )""""; + this->setShaderSource(source); +} - // --- Final pass: Distance field → bevel height --- - cache->bindFboToTexture(ctx.outputTextureId); - ctx.useShader(bevelShader); - ctx.bindTexture(bevelShader, "u_jfa", texA, 0); - gl->glUniform1f( - gl->glGetUniformLocation(bevelShader, "u_distance"), - data.distance); - ctx.drawQuad(); +void BevelNode::cpuProcess(void* glPtr, const RenderCommand& command) +{ + // Cast to QOpenGLFunctions_3_2_Core + auto gl = static_cast(glPtr); - // Intermediates released by worker after render() returns + // Get the first input texture if available + GLuint inputTextureId = 0; + if (!command.inputs.isEmpty()) { + inputTextureId = command.inputs[0].textureId; } -private: - static QString standardVert() - { - return RenderResourceCache::standardVertexSource(); + if (inputTextureId == 0) + return; + + int width = command.textureWidth; + int height = command.textureHeight; + + // Allocate buffers + int gridSize = width * height; + std::vector readPixels(gridSize * 4); + std::vector resultPixels(gridSize * 4); + + // Read pixels from input texture + GLuint fbo; + gl->glGenFramebuffers(1, &fbo); + gl->glBindFramebuffer(GL_FRAMEBUFFER, fbo); + gl->glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, inputTextureId, 0); + + if (gl->glCheckFramebufferStatus(GL_FRAMEBUFFER) == + GL_FRAMEBUFFER_COMPLETE) { + gl->glReadPixels(0, 0, width, height, GL_RGBA, GL_FLOAT, + readPixels.data()); } - static QString seedFrag() - { - return R""""( - #version 150 core - in vec2 v_texCoord; - out vec4 fragColor; - - uniform sampler2D image; - uniform vec2 _textureSize; - uniform float u_threshold; - - void main() { - vec2 uv = v_texCoord; - float v = texture(image, uv).r; - vec2 texel = vec2(1.0) / _textureSize; - - // Check 4-neighbors for threshold crossing (edge detection) - float n = texture(image, uv + vec2(0.0, texel.y)).r; - float s = texture(image, uv - vec2(0.0, texel.y)).r; - float e = texture(image, uv + vec2(texel.x, 0.0)).r; - float w = texture(image, uv - vec2(texel.x, 0.0)).r; - - bool isEdge = (v >= u_threshold) != (n >= u_threshold) || - (v >= u_threshold) != (s >= u_threshold) || - (v >= u_threshold) != (e >= u_threshold) || - (v >= u_threshold) != (w >= u_threshold); - - if (isEdge) - fragColor = vec4(uv, v, 1.0); // Seed: store own UV - else - fragColor = vec4(-1.0, -1.0, v, 0.0); // No seed - } - )""""; + gl->glBindFramebuffer(GL_FRAMEBUFFER, 0); + gl->glDeleteFramebuffers(1, &fbo); + + // Allocate working arrays + int maxSize = std::max(width, height); + std::vector f(maxSize * 3); + std::vector z(maxSize * 3 + 1); + std::vector v(maxSize * 3); + + std::vector gridOuter(gridSize); + std::vector gridInner(gridSize); + std::vector grid(gridSize); + + // Convert pixels to distance fields + for (int i = 0; i < gridSize; i++) { + float a = readPixels[i * 4 + 0]; // Use red channel + + gridOuter[i] = (a == 1.0f) ? 0.0 + : (a == 0.0f) ? INF + : std::pow(std::max(0.0f, 0.5f - a), 2); + gridInner[i] = (a == 1.0f) ? INF + : (a == 0.0f) ? 0.0 + : std::pow(std::max(0.0f, a - 0.5f), 2); } - static QString jfaFrag() - { - return R""""( - #version 150 core - in vec2 v_texCoord; - out vec4 fragColor; - - uniform sampler2D u_input; - uniform vec2 _textureSize; - uniform int u_stepSize; - - void main() { - vec2 uv = v_texCoord; - vec2 texel = vec2(1.0) / _textureSize; - vec4 best = texture(u_input, uv); - float bestDist = (best.a < 0.5) ? 9999.0 : length(uv - best.xy); - - // Check 3x3 neighborhood at current step size - for (int y = -1; y <= 1; y++) { - for (int x = -1; x <= 1; x++) { - if (x == 0 && y == 0) continue; - - vec2 offset = vec2(float(x), float(y)) - * float(u_stepSize) * texel; - vec4 neighbor = texture(u_input, uv + offset); - - if (neighbor.a < 0.5) continue; // No seed - - float d = length(uv - neighbor.xy); - if (d < bestDist) { - bestDist = d; - best = neighbor; - } - } - } - - fragColor = best; - } - )""""; + // Apply Euclidean Distance Transform + edt(gridOuter, width, height, f, v, z); + edt(gridInner, width, height, f, v, z); + + // Get distance property from RenderCommand props + float radius = 50.0f; + for (const auto& prop : command.props) { + if (prop.propName == "distance" && prop.propType == PropType::Float) { + radius = prop.value.toFloat(); + break; + } } + float offset = 0.25f; + + // Calculate bevel + float minVal = 1.0f; + float maxVal = 0.0f; - static QString bevelFrag() - { - return R""""( - #version 150 core - in vec2 v_texCoord; - out vec4 fragColor; - - uniform sampler2D u_jfa; - uniform vec2 _textureSize; - uniform float u_distance; - - void main() { - vec2 uv = v_texCoord; - vec4 data = texture(u_jfa, uv); - - if (data.a < 0.5) { - // No nearest seed found - fragColor = vec4(0.0, 0.0, 0.0, 1.0); - return; - } - - // Convert UV-space distance to pixel distance - float dist = length(uv - data.xy) - * max(_textureSize.x, _textureSize.y); - - float bevel = 1.0 - clamp(dist / u_distance, 0.0, 1.0); - fragColor = vec4(vec3(bevel), 1.0); - } - )""""; + for (int i = 0; i < gridSize; i++) { + double d = std::sqrt(gridOuter[i]) - std::sqrt(gridInner[i]); + float col = VALUE_MAX - VALUE_MAX * (d / radius + offset); + col = std::max(0.0f, std::min(VALUE_MAX, col)); + + minVal = std::min(minVal, col); + maxVal = std::max(maxVal, col); + grid[i] = col; } -}; -// ============================================================================ -// BevelNode -// ============================================================================ + // Normalize and invert + float range = maxVal - minVal; + float scale = (range > 0.0f) ? (1.0f / range) : 1.0f; -void BevelNode::init() -{ - this->title = "Bevel"; - this->addInput("image"); - this->addFloatProp("distance", "Distance", 50.0, 0.0, 200.0, 0.5); - this->addFloatProp("threshold", "Threshold", 0.5, 0.0, 1.0, 0.01); + for (int i = 0; i < gridSize; i++) { + float col = 1.0f - (grid[i] - minVal) * scale; // de-invert - // Passthrough shader for initialization (not used during rendering — - // custom renderer handles all passes) - auto source = R""""( - vec4 process(vec2 uv) - { - return texture(image, uv); - } - )""""; - this->setShaderSource(source); + resultPixels[i * 4 + 0] = col; + resultPixels[i * 4 + 1] = col; + resultPixels[i * 4 + 2] = col; + resultPixels[i * 4 + 3] = 1.0f; + } + + // Upload result to texture + gl->glBindTexture(GL_TEXTURE_2D, command.textureId); + gl->glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, width, height, 0, GL_RGBA, + GL_FLOAT, resultPixels.data()); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); + gl->glBindTexture(GL_TEXTURE_2D, 0); } -std::shared_ptr BevelNode::createRenderer() +// 2D Euclidean squared distance transform by Felzenszwalb & Huttenlocher +// https://cs.brown.edu/~pff/papers/dt-final.pdf +static void edt(std::vector& data, int width, int height, + std::vector& f, std::vector& v, + std::vector& z) { - return std::make_shared(); + for (int x = 0; x < width; x++) + edt1d(data, x, width, height, f, v, z); + for (int y = 0; y < height; y++) + edt1d(data, y * width, 1, width, f, v, z); } -std::shared_ptr BevelNode::createRenderData() +// 1D squared distance transform +static void edt1d(std::vector& grid, int offset, int stride, int length, + std::vector& f, std::vector& v, + std::vector& z) { - auto data = std::make_shared(); - - auto distProp = static_cast(this->getProp("distance")); - if (distProp) - data->distance = distProp->value; - - auto threshProp = static_cast(this->getProp("threshold")); - if (threshProp) - data->threshold = threshProp->value; + v[0] = 0; + z[0] = -INF; + z[1] = INF; + + // Load line in array three times for wrapping + for (int q = 0; q < length; q++) + f[q] = grid[offset + q * stride]; + for (int q = 0; q < length; q++) + f[q + length] = grid[offset + q * stride]; + for (int q = 0; q < length; q++) + f[q + length + length] = grid[offset + q * stride]; + + int k = 0; + for (int q = 1; q < length * 3; q++) { + double s; + do { + int r = v[k]; + s = (f[q] - f[r] + q * q - r * r) / (q - r) / 2.0; + } while (s <= z[k] && --k > -1); + + k++; + v[k] = q; + z[k] = s; + z[k + 1] = INF; + } - return data; -} + // Copy over middle section + for (int q = length, k = 0; q < length + length; q++) { + while (z[k + 1] < q) + k++; + int r = v[k]; + grid[offset + (q - length) * stride] = f[r] + (q - r) * (q - r); + } +} \ No newline at end of file diff --git a/src/texturelab/libraries/v3/bevelv2.cpp b/src/texturelab/libraries/v3/bevelv2.cpp new file mode 100644 index 00000000..e4617da1 --- /dev/null +++ b/src/texturelab/libraries/v3/bevelv2.cpp @@ -0,0 +1,248 @@ +#include "../../graphics/noderenderer.h" +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +#include +#include +#include + +// ============================================================================ +// BevelV2RenderData — parameters passed to the render thread +// ============================================================================ + +struct BevelV2RenderData : public NodeRenderData { + float distance = 50.0f; + float threshold = 0.5f; +}; + +// ============================================================================ +// BevelV2Renderer — JFA-based GPU bevel implementation +// ============================================================================ + +class BevelV2Renderer : public NodeTextureRenderer { +public: + void render(NodeRenderContext& ctx, + const NodeRenderData& baseData) override + { + auto& data = static_cast(baseData); + auto gl = ctx.gl; + auto cache = ctx.cache; + + int w = ctx.textureWidth; + int h = ctx.textureHeight; + + // No input connected — output black + if (ctx.inputs.isEmpty() || ctx.inputs[0].textureId == 0) { + cache->bindFboToTexture(ctx.outputTextureId); + gl->glViewport(0, 0, w, h); + gl->glClearColor(0, 0, 0, 1); + gl->glClear(GL_COLOR_BUFFER_BIT); + return; + } + + // Compile shaders (cached after first call) + GLuint seedShader = cache->getOrCompileShader( + "jfa_seed", standardVert(), seedFrag()); + GLuint jfaShader = cache->getOrCompileShader( + "jfa_step", standardVert(), jfaFrag()); + GLuint bevelShader = cache->getOrCompileShader( + "jfa_bevel", standardVert(), bevelFrag()); + + // Acquire two intermediate textures for ping-pong + GLuint texA = cache->acquireTexture(w, h); + GLuint texB = cache->acquireTexture(w, h); + + // --- Pass 0: Seed initialization --- + // Detect edges where the input crosses the threshold + cache->bindFboToTexture(texA); + ctx.useShader(seedShader); + ctx.bindTexture(seedShader, "image", ctx.inputs[0].textureId, 0); + gl->glUniform1f( + gl->glGetUniformLocation(seedShader, "u_threshold"), + data.threshold); + ctx.drawQuad(); + + // --- Passes 1..N: JFA iteration (ping-pong) --- + int maxDim = std::max(w, h); + int stepSize = maxDim / 2; + + while (stepSize >= 1) { + cache->bindFboToTexture(texB); + ctx.useShader(jfaShader); + ctx.bindTexture(jfaShader, "u_input", texA, 0); + gl->glUniform1i( + gl->glGetUniformLocation(jfaShader, "u_stepSize"), stepSize); + ctx.drawQuad(); + + std::swap(texA, texB); + stepSize /= 2; + } + + // --- Final pass: Distance field → bevel height --- + cache->bindFboToTexture(ctx.outputTextureId); + ctx.useShader(bevelShader); + ctx.bindTexture(bevelShader, "u_jfa", texA, 0); + gl->glUniform1f( + gl->glGetUniformLocation(bevelShader, "u_distance"), + data.distance); + ctx.drawQuad(); + + // Intermediates released by worker after render() returns + } + +private: + static QString standardVert() + { + return RenderResourceCache::standardVertexSource(); + } + + static QString seedFrag() + { + return R""""( + #version 150 core + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D image; + uniform vec2 _textureSize; + uniform float u_threshold; + + void main() { + vec2 uv = v_texCoord; + float v = texture(image, uv).r; + vec2 texel = vec2(1.0) / _textureSize; + + // Check 4-neighbors for threshold crossing (edge detection) + float n = texture(image, uv + vec2(0.0, texel.y)).r; + float s = texture(image, uv - vec2(0.0, texel.y)).r; + float e = texture(image, uv + vec2(texel.x, 0.0)).r; + float w = texture(image, uv - vec2(texel.x, 0.0)).r; + + bool isEdge = (v >= u_threshold) != (n >= u_threshold) || + (v >= u_threshold) != (s >= u_threshold) || + (v >= u_threshold) != (e >= u_threshold) || + (v >= u_threshold) != (w >= u_threshold); + + if (isEdge) + fragColor = vec4(uv, v, 1.0); // Seed: store own UV + else + fragColor = vec4(-1.0, -1.0, v, 0.0); // No seed + } + )""""; + } + + static QString jfaFrag() + { + return R""""( + #version 150 core + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_input; + uniform vec2 _textureSize; + uniform int u_stepSize; + + void main() { + vec2 uv = v_texCoord; + vec2 texel = vec2(1.0) / _textureSize; + vec4 best = texture(u_input, uv); + float bestDist = (best.a < 0.5) ? 9999.0 : length(uv - best.xy); + + // Check 3x3 neighborhood at current step size + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + if (x == 0 && y == 0) continue; + + vec2 offset = vec2(float(x), float(y)) + * float(u_stepSize) * texel; + vec4 neighbor = texture(u_input, uv + offset); + + if (neighbor.a < 0.5) continue; // No seed + + float d = length(uv - neighbor.xy); + if (d < bestDist) { + bestDist = d; + best = neighbor; + } + } + } + + fragColor = best; + } + )""""; + } + + static QString bevelFrag() + { + return R""""( + #version 150 core + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_jfa; + uniform vec2 _textureSize; + uniform float u_distance; + + void main() { + vec2 uv = v_texCoord; + vec4 data = texture(u_jfa, uv); + + if (data.a < 0.5) { + // No nearest seed found + fragColor = vec4(0.0, 0.0, 0.0, 1.0); + return; + } + + // Convert UV-space distance to pixel distance + float dist = length(uv - data.xy) + * max(_textureSize.x, _textureSize.y); + + float bevel = 1.0 - clamp(dist / u_distance, 0.0, 1.0); + fragColor = vec4(vec3(bevel), 1.0); + } + )""""; + } +}; + +// ============================================================================ +// BevelV2Node +// ============================================================================ + +void BevelV2Node::init() +{ + this->title = "Bevel V2"; + this->addInput("image"); + this->addFloatProp("distance", "Distance", 50.0, 0.0, 200.0, 0.5); + this->addFloatProp("threshold", "Threshold", 0.5, 0.0, 1.0, 0.01); + + // Passthrough shader for initialization (not used during rendering — + // custom renderer handles all passes) + auto source = R""""( + vec4 process(vec2 uv) + { + return texture(image, uv); + } + )""""; + this->setShaderSource(source); +} + +std::shared_ptr BevelV2Node::createRenderer() +{ + return std::make_shared(); +} + +std::shared_ptr BevelV2Node::createRenderData() +{ + auto data = std::make_shared(); + + auto distProp = static_cast(this->getProp("distance")); + if (distProp) + data->distance = distProp->value; + + auto threshProp = static_cast(this->getProp("threshold")); + if (threshProp) + data->threshold = threshProp->value; + + return data; +} From c72f5b22b31260af2c058d01c2b486d1be429524 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 25 Mar 2026 20:49:45 -0500 Subject: [PATCH 008/164] bevelv2 complete --- src/texturelab/libraries/v3/bevelv2.cpp | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/texturelab/libraries/v3/bevelv2.cpp b/src/texturelab/libraries/v3/bevelv2.cpp index e4617da1..212795e5 100644 --- a/src/texturelab/libraries/v3/bevelv2.cpp +++ b/src/texturelab/libraries/v3/bevelv2.cpp @@ -111,20 +111,10 @@ class BevelV2Renderer : public NodeTextureRenderer { void main() { vec2 uv = v_texCoord; float v = texture(image, uv).r; - vec2 texel = vec2(1.0) / _textureSize; - - // Check 4-neighbors for threshold crossing (edge detection) - float n = texture(image, uv + vec2(0.0, texel.y)).r; - float s = texture(image, uv - vec2(0.0, texel.y)).r; - float e = texture(image, uv + vec2(texel.x, 0.0)).r; - float w = texture(image, uv - vec2(texel.x, 0.0)).r; - - bool isEdge = (v >= u_threshold) != (n >= u_threshold) || - (v >= u_threshold) != (s >= u_threshold) || - (v >= u_threshold) != (e >= u_threshold) || - (v >= u_threshold) != (w >= u_threshold); - if (isEdge) + // Black pixels (below threshold) are seeds — + // JFA spreads distance from them into white regions + if (v < u_threshold) fragColor = vec4(uv, v, 1.0); // Seed: store own UV else fragColor = vec4(-1.0, -1.0, v, 0.0); // No seed @@ -198,7 +188,7 @@ class BevelV2Renderer : public NodeTextureRenderer { float dist = length(uv - data.xy) * max(_textureSize.x, _textureSize.y); - float bevel = 1.0 - clamp(dist / u_distance, 0.0, 1.0); + float bevel = clamp(dist / u_distance, 0.0, 1.0); fragColor = vec4(vec3(bevel), 1.0); } )""""; From d0a5e20cf13d573b378ca58734c4937c7401991b Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 25 Mar 2026 21:05:21 -0500 Subject: [PATCH 009/164] add curvature shapes props to bevel --- src/texturelab/libraries/v3/bevelv2.cpp | 36 ++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/texturelab/libraries/v3/bevelv2.cpp b/src/texturelab/libraries/v3/bevelv2.cpp index 212795e5..b7c062dd 100644 --- a/src/texturelab/libraries/v3/bevelv2.cpp +++ b/src/texturelab/libraries/v3/bevelv2.cpp @@ -14,6 +14,7 @@ struct BevelV2RenderData : public NodeRenderData { float distance = 50.0f; float threshold = 0.5f; + int shape = 0; // 0=Linear, 1=Round, 2=Smooth }; // ============================================================================ @@ -53,8 +54,7 @@ class BevelV2Renderer : public NodeTextureRenderer { GLuint texA = cache->acquireTexture(w, h); GLuint texB = cache->acquireTexture(w, h); - // --- Pass 0: Seed initialization --- - // Detect edges where the input crosses the threshold + // --- Seed initialization --- cache->bindFboToTexture(texA); ctx.useShader(seedShader); ctx.bindTexture(seedShader, "image", ctx.inputs[0].textureId, 0); @@ -63,7 +63,7 @@ class BevelV2Renderer : public NodeTextureRenderer { data.threshold); ctx.drawQuad(); - // --- Passes 1..N: JFA iteration (ping-pong) --- + // --- JFA iteration (ping-pong) --- int maxDim = std::max(w, h); int stepSize = maxDim / 2; @@ -86,9 +86,10 @@ class BevelV2Renderer : public NodeTextureRenderer { gl->glUniform1f( gl->glGetUniformLocation(bevelShader, "u_distance"), data.distance); + gl->glUniform1i( + gl->glGetUniformLocation(bevelShader, "u_shape"), + data.shape); ctx.drawQuad(); - - // Intermediates released by worker after render() returns } private: @@ -173,6 +174,11 @@ class BevelV2Renderer : public NodeTextureRenderer { uniform sampler2D u_jfa; uniform vec2 _textureSize; uniform float u_distance; + uniform int u_shape; + + #define SHAPE_LINEAR 0 + #define SHAPE_ROUND 1 + #define SHAPE_SMOOTH 2 void main() { vec2 uv = v_texCoord; @@ -188,7 +194,20 @@ class BevelV2Renderer : public NodeTextureRenderer { float dist = length(uv - data.xy) * max(_textureSize.x, _textureSize.y); - float bevel = clamp(dist / u_distance, 0.0, 1.0); + float t = clamp(dist / u_distance, 0.0, 1.0); + + float bevel; + if (u_shape == SHAPE_ROUND) { + // Circular cross-section: quarter-circle falloff + bevel = sqrt(1.0 - (1.0 - t) * (1.0 - t)); + } else if (u_shape == SHAPE_SMOOTH) { + // S-curve: smoothstep for gentle transitions + bevel = smoothstep(0.0, 1.0, t); + } else { + // Linear ramp (default) + bevel = t; + } + fragColor = vec4(vec3(bevel), 1.0); } )""""; @@ -205,6 +224,7 @@ void BevelV2Node::init() this->addInput("image"); this->addFloatProp("distance", "Distance", 50.0, 0.0, 200.0, 0.5); this->addFloatProp("threshold", "Threshold", 0.5, 0.0, 1.0, 0.01); + this->addEnumProp("shape", "Shape", {"Linear", "Round", "Smooth"}); // Passthrough shader for initialization (not used during rendering — // custom renderer handles all passes) @@ -234,5 +254,9 @@ std::shared_ptr BevelV2Node::createRenderData() if (threshProp) data->threshold = threshProp->value; + auto shapeProp = static_cast(this->getProp("shape")); + if (shapeProp) + data->shape = shapeProp->index; + return data; } From cb4eba618c9c9f09dd83f266d473ee5b5aff59f2 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 25 Mar 2026 22:29:05 -0500 Subject: [PATCH 010/164] reimplement floodfill nodes --- src/texturelab/CMakeLists.txt | 7 + src/texturelab/libraries/library.cpp | 19 ++ src/texturelab/libraries/libv3.h | 37 ++++ src/texturelab/libraries/v3/floodfillv2.cpp | 209 ++++++++++++++++++ .../libraries/v3/floodfillv2sampler.cpp | 124 +++++++++++ .../libraries/v3/floodfillv2tobbox.cpp | 41 ++++ .../libraries/v3/floodfillv2tocolor.cpp | 29 +++ .../libraries/v3/floodfillv2togradient.cpp | 56 +++++ .../libraries/v3/floodfillv2torandomcolor.cpp | 32 +++ .../v3/floodfillv2torandomintensity.cpp | 29 +++ 10 files changed, 583 insertions(+) create mode 100644 src/texturelab/libraries/v3/floodfillv2.cpp create mode 100644 src/texturelab/libraries/v3/floodfillv2sampler.cpp create mode 100644 src/texturelab/libraries/v3/floodfillv2tobbox.cpp create mode 100644 src/texturelab/libraries/v3/floodfillv2tocolor.cpp create mode 100644 src/texturelab/libraries/v3/floodfillv2togradient.cpp create mode 100644 src/texturelab/libraries/v3/floodfillv2torandomcolor.cpp create mode 100644 src/texturelab/libraries/v3/floodfillv2torandomintensity.cpp diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 5fcb2a53..c1c0a9d9 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -105,6 +105,13 @@ set(LIBRARYV3 ./libraries/v3/maskedblur.cpp ./libraries/v3/rays.cpp ./libraries/v3/swirl.cpp + ./libraries/v3/floodfillv2.cpp + ./libraries/v3/floodfillv2tocolor.cpp + ./libraries/v3/floodfillv2torandomcolor.cpp + ./libraries/v3/floodfillv2torandomintensity.cpp + ./libraries/v3/floodfillv2tobbox.cpp + ./libraries/v3/floodfillv2togradient.cpp + ./libraries/v3/floodfillv2sampler.cpp ) set(PROJECT_SOURCES diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index 38bf5dda..084bd8e1 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -199,6 +199,25 @@ Library* createLibraryV3() ":nodes/blurv2.png"); lib->addNode("rays", "Rays", ":nodes/bevel.png"); lib->addNode("swirl", "Swirl", ":nodes/bevel.png"); + lib->addNode("floodfillv2", "Flood Fill V2", + ":nodes/floodfill.png"); + lib->addNode("floodfillv2tocolor", + "FF To Color V2", + ":nodes/floodfilltocolor.png"); + lib->addNode("floodfillv2torandomcolor", + "FF To Random Color V2", + ":nodes/floodfilltorandomcolor.png"); + lib->addNode( + "floodfillv2torandomintensity", "FF To Random Intensity V2", + ":nodes/floodfilltorandomintensity.png"); + lib->addNode("floodfillv2tobbox", "FF To BBox V2", + ":nodes/floodfilltobbox.png"); + lib->addNode("floodfillv2togradient", + "FF To Gradient V2", + ":nodes/floodfilltogradient.png"); + lib->addNode("floodfillv2sampler", + "FF Sampler V2", + ":nodes/floodfillsampler.png"); return lib; } \ No newline at end of file diff --git a/src/texturelab/libraries/libv3.h b/src/texturelab/libraries/libv3.h index c33c0ae1..02870604 100644 --- a/src/texturelab/libraries/libv3.h +++ b/src/texturelab/libraries/libv3.h @@ -34,3 +34,40 @@ class SwirlNode : public TextureNode { public: virtual void init() override; }; + +class FloodFillV2Node : public TextureNode { +public: + virtual void init() override; + std::shared_ptr createRenderer() override; + std::shared_ptr createRenderData() override; +}; + +class FloodFillV2ToColorNode : public TextureNode { +public: + virtual void init() override; +}; + +class FloodFillV2ToRandomColorNode : public TextureNode { +public: + virtual void init() override; +}; + +class FloodFillV2ToRandomIntensityNode : public TextureNode { +public: + virtual void init() override; +}; + +class FloodFillV2ToBBoxNode : public TextureNode { +public: + virtual void init() override; +}; + +class FloodFillV2ToGradientNode : public TextureNode { +public: + virtual void init() override; +}; + +class FloodFillV2SamplerNode : public TextureNode { +public: + virtual void init() override; +}; diff --git a/src/texturelab/libraries/v3/floodfillv2.cpp b/src/texturelab/libraries/v3/floodfillv2.cpp new file mode 100644 index 00000000..e7313644 --- /dev/null +++ b/src/texturelab/libraries/v3/floodfillv2.cpp @@ -0,0 +1,209 @@ +#include "../../graphics/noderenderer.h" +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +#include +#include +#include +#include +#include +#include + +// ============================================================================ +// FloodFillV2RenderData +// ============================================================================ + +struct FloodFillV2RenderData : public NodeRenderData { + float threshold = 0.1f; +}; + +// ============================================================================ +// FloodFillV2Renderer — CPU BFS flood fill via NodeTextureRenderer +// +// Output encoding (RGBA32F per pixel): +// R = island origin x (UV, wrapped to [0,1], quantized to texel center) +// G = island origin y (same) +// B = bbox width / texture width +// A = bbox height / texture height +// Background = (0, 0, 0, 0) +// +// The origin is stored directly — downstream nodes use it as a per-island +// identity without reconstruction arithmetic, eliminating precision issues. +// UV-within-bbox is derived by downstream nodes as (uv - origin) / bbox_size. +// ============================================================================ + +class FloodFillV2Renderer : public NodeTextureRenderer { +public: + void render(NodeRenderContext& ctx, + const NodeRenderData& baseData) override + { + auto& data = static_cast(baseData); + auto gl = ctx.gl; + auto cache = ctx.cache; + + int w = ctx.textureWidth; + int h = ctx.textureHeight; + + // No input — output black + if (ctx.inputs.isEmpty() || ctx.inputs[0].textureId == 0) { + cache->bindFboToTexture(ctx.outputTextureId); + gl->glViewport(0, 0, w, h); + gl->glClearColor(0, 0, 0, 1); + gl->glClear(GL_COLOR_BUFFER_BIT); + return; + } + + int gridSize = w * h; + + // Read input pixels via FBO + std::vector readPixels(gridSize * 4); + cache->bindFboToTexture(ctx.inputs[0].textureId); + gl->glReadPixels(0, 0, w, h, GL_RGBA, GL_FLOAT, readPixels.data()); + + // Helper: wrap pixel coordinate into [0, bound) + auto wrapAround = [](int value, int bound) -> int { + return ((value % bound) + bound) % bound; + }; + + // Helper: get pixel intensity (average of RGB) + auto getIntensity = [&](int x, int y) -> float { + int idx = 4 * (w * y + x); + return (readPixels[idx] + readPixels[idx + 1] + readPixels[idx + 2]) + / 3.0f; + }; + + // BFS flood fill with wrap-around + struct Island { + int left = INT_MAX, top = INT_MAX; + int right = INT_MIN, bottom = INT_MIN; + struct Pixel { + int localX, localY, globalX, globalY; + }; + std::vector pixels; + + void expand(int x, int y) + { + left = std::min(left, x); + top = std::min(top, y); + right = std::max(right, x); + bottom = std::max(bottom, y); + } + int width() const { return right - left; } + int height() const { return bottom - top; } + }; + + std::vector visited(gridSize, false); + std::vector islands; + + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + if (visited[y * w + x]) + continue; + + Island island; + std::queue> queue; + queue.push({x, y}); + + while (!queue.empty()) { + auto [gx, gy] = queue.front(); + queue.pop(); + + int lx = wrapAround(gx, w); + int ly = wrapAround(gy, h); + + if (visited[ly * w + lx]) + continue; + visited[ly * w + lx] = true; + + if (getIntensity(lx, ly) < data.threshold) + continue; + + island.expand(gx, gy); + island.pixels.push_back({lx, ly, gx, gy}); + + queue.push({gx + 1, gy}); + queue.push({gx - 1, gy}); + queue.push({gx, gy + 1}); + queue.push({gx, gy - 1}); + } + + if (island.width() > 0 && island.height() > 0) + islands.push_back(std::move(island)); + } + } + + // Build output texture + // Encoding: (origin_x, origin_y, bbox_w, bbox_h) + // origin is the top-left of the bbox, wrapped to [0,1] UV space, + // quantized to texel centers for consistency across all pixels + std::vector results(gridSize * 4, 0.0f); + + float invW = 1.0f / w; + float invH = 1.0f / h; + + for (const auto& island : islands) { + float bboxW = island.width() * invW; + float bboxH = island.height() * invH; + + // Origin in UV space, wrapped to [0,1] and quantized to texel center + int originLocalX = wrapAround(island.left, w); + int originLocalY = wrapAround(island.top, h); + float originU = (originLocalX + 0.5f) * invW; + float originV = (originLocalY + 0.5f) * invH; + + for (const auto& px : island.pixels) { + int idx = 4 * (w * px.localY + px.localX); + results[idx + 0] = originU; + results[idx + 1] = originV; + results[idx + 2] = bboxW; + results[idx + 3] = bboxH; + } + } + + // Upload result to output texture + gl->glBindTexture(GL_TEXTURE_2D, ctx.outputTextureId); + gl->glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, w, h, 0, GL_RGBA, + GL_FLOAT, results.data()); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); + gl->glBindTexture(GL_TEXTURE_2D, 0); + } +}; + +// ============================================================================ +// FloodFillV2Node +// ============================================================================ + +void FloodFillV2Node::init() +{ + this->title = "Flood Fill V2"; + this->addInput("image"); + this->addFloatProp("threshold", "Threshold", 0.1, 0.0, 1.0, 0.01); + + auto source = R""""( + vec4 process(vec2 uv) + { + return texture(image, uv); + } + )""""; + this->setShaderSource(source); +} + +std::shared_ptr FloodFillV2Node::createRenderer() +{ + return std::make_shared(); +} + +std::shared_ptr FloodFillV2Node::createRenderData() +{ + auto data = std::make_shared(); + + auto threshProp = static_cast(this->getProp("threshold")); + if (threshProp) + data->threshold = threshProp->value; + + return data; +} diff --git a/src/texturelab/libraries/v3/floodfillv2sampler.cpp b/src/texturelab/libraries/v3/floodfillv2sampler.cpp new file mode 100644 index 00000000..c4bf6edd --- /dev/null +++ b/src/texturelab/libraries/v3/floodfillv2sampler.cpp @@ -0,0 +1,124 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +#include + +void FloodFillV2SamplerNode::init() +{ + this->title = "FF Sampler V2"; + + this->addInput("floodfill"); + this->addInput("image"); + this->addInput("mask"); + this->addInput("size"); + this->addInput("intensity"); + + this->addFloatProp("rot", "Rotation", 0, 0, 360, 0.1); + this->addFloatProp("rotRand", "Random Rotation", 0, 0, 1.0, 0.01); + this->addFloatProp("posRand", "Random Position", 0, 0, 1.0, 0.01); + this->addFloatProp("intensityRand", "Random Intensity", 0, 0, 1.0, 0.01); + this->addFloatProp("scale", "Scale", 1, 0, 4, 0.1); + this->addFloatProp("scaleRand", "Scale random", 0, 0, 1, 0.1); + + this->addColorProp("bg", "Background Color", QColor()); + + auto source = R""""( + mat3 transMat(vec2 t) + { + return mat3(vec3(1.0,0.0,0.0), vec3(0.0,1.0,0.0), vec3(t, 1.0)); + } + + mat3 scaleMat(vec2 s) + { + return mat3(vec3(s.x,0.0,0.0), vec3(0.0,s.y,0.0), vec3(0.0, 0.0, 1.0)); + } + + mat3 rotMat(float rot) + { + float r = radians(rot); + return mat3(vec3(cos(r), -sin(r),0.0), vec3(sin(r), cos(r),0.0), vec3(0.0, 0.0, 1.0)); + } + + vec2 transformUV(vec2 uv, vec2 translate, float rot, vec2 scale) + { + mat3 trans = transMat(vec2(0.5, 0.5)) * + transMat(vec2(translate.x, translate.y)) * + rotMat(rot) * + scaleMat(vec2(scale.x, scale.y)) * + transMat(vec2(-0.5, -0.5)); + + vec3 res = inverse(trans) * vec3(uv, 1.0); + uv = res.xy; + + return clamp(uv, vec2(0.0), vec2(1.0)); + } + + float randomFloatRange(vec2 seed, int offset, float fmin, float fmax) + { + float r = _rand(vec2(_seed) + seed + vec2(float(offset)) * 0.01); + return fmin + (fmax - fmin) * r; + } + + vec4 sampleImage(sampler2D img, vec2 uv) + { + if (uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0) + return texture(img, uv); + return vec4(prop_bg.rgb, 1.0); + } + + vec4 process(vec2 uv) + { + vec4 data = texture(floodfill, uv); + + if (data.ba == vec2(0.0, 0.0)) + return vec4(prop_bg.rgb, 1.0); + + // Origin and bbox from encoding + vec2 origin = data.rg; + vec2 bboxSize = data.ba; + vec2 center = fract(origin + bboxSize * vec2(0.5)); + + // Compute UV within bbox for this pixel (wrap-aware) + vec2 uvInBbox = mod(uv - origin + 1.0, 1.0) / bboxSize; + + // Per-island random values seeded from origin + vec2 randOffset = vec2(0.0); + { + float rx = randomFloatRange(origin, 4, -1.0, 1.0); + float ry = randomFloatRange(origin, 5, -1.0, 1.0); + randOffset = normalize(vec2(rx, ry)) * prop_posRand; + } + + float rot = randomFloatRange(origin, 3, -180.0, 180.0) * prop_rotRand + prop_rot; + + // Mask + if (mask_connected) { + float m = texture(mask, center).r; + if (m < 0.001) + return vec4(prop_bg.rgb, 1.0); + } + + // Scale + float s = prop_scale; + if (size_connected) + s *= texture(size, center).r; + float randScale = randomFloatRange(origin, 3, 0.0, 1.0); + s = mix(s, randScale, prop_scaleRand); + + // Intensity + float intens = 1.0; + if (intensity_connected) + intens *= texture(intensity, center).r; + float randIntensity = randomFloatRange(origin, 8, 0.0, 1.0); + intens = mix(intens, randIntensity, prop_intensityRand); + + vec2 finalUv = transformUV(uvInBbox, randOffset, rot, vec2(s)); + vec3 color = texture(image, finalUv).rgb; + + return vec4(color * intens, 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/floodfillv2tobbox.cpp b/src/texturelab/libraries/v3/floodfillv2tobbox.cpp new file mode 100644 index 00000000..728f231e --- /dev/null +++ b/src/texturelab/libraries/v3/floodfillv2tobbox.cpp @@ -0,0 +1,41 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void FloodFillV2ToBBoxNode::init() +{ + this->title = "FF To BBox V2"; + + this->addInput("floodfill"); + + this->addEnumProp("function", "Function", + {"max(x,y)", "min(x,y)", "x", "y", "length(x,y)"}); + + auto source = R""""( + vec4 process(vec2 uv) + { + vec4 data = texture(floodfill, uv); + + if (data.ba == vec2(0.0, 0.0)) + return vec4(0.0, 0.0, 0.0, 1.0); + + // BA = bbox size (width, height) relative to texture + float intensity = 0.0; + + if (prop_function == 0) + intensity = max(data.b, data.a); + else if (prop_function == 1) + intensity = min(data.b, data.a); + else if (prop_function == 2) + intensity = data.b; + else if (prop_function == 3) + intensity = data.a; + else if (prop_function == 4) + intensity = sqrt(data.b * data.b + data.a * data.a); + + return vec4(vec3(intensity), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/floodfillv2tocolor.cpp b/src/texturelab/libraries/v3/floodfillv2tocolor.cpp new file mode 100644 index 00000000..e1fa274c --- /dev/null +++ b/src/texturelab/libraries/v3/floodfillv2tocolor.cpp @@ -0,0 +1,29 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void FloodFillV2ToColorNode::init() +{ + this->title = "FF To Color V2"; + + this->addInput("floodfill"); + this->addInput("color"); + + auto source = R""""( + vec4 process(vec2 uv) + { + vec4 data = texture(floodfill, uv); + + // Background check: bbox size is zero + if (data.ba == vec2(0.0, 0.0)) + return vec4(0.0, 0.0, 0.0, 1.0); + + // Origin is stored directly in RG — no reconstruction needed + vec2 origin = data.rg; + + return texture(color, origin); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/floodfillv2togradient.cpp b/src/texturelab/libraries/v3/floodfillv2togradient.cpp new file mode 100644 index 00000000..71072c4b --- /dev/null +++ b/src/texturelab/libraries/v3/floodfillv2togradient.cpp @@ -0,0 +1,56 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void FloodFillV2ToGradientNode::init() +{ + this->title = "FF To Gradient V2"; + + this->addInput("floodfill"); + + this->addFloatProp("angle", "Angle", 0, 0, 360, 1); + this->addFloatProp("variation", "Angle Variation", 0, 0, 1.0, 0.05); + + auto source = R""""( + mat2 buildRot(float rot) + { + float r = radians(rot); + return mat2(cos(r), -sin(r), sin(r), cos(r)); + } + + float distAlongDir(vec2 x, vec2 dir) + { + return dot(x, dir) / dot(dir, dir); + } + + vec4 process(vec2 uv) + { + vec4 data = texture(floodfill, uv); + + if (data.ba == vec2(0.0, 0.0)) + return vec4(0.0, 0.0, 0.0, 1.0); + + vec2 origin = data.rg; + vec2 bboxSize = data.ba; + + float radius = length(bboxSize) * 0.5; + + // Per-island random rotation from origin seed + float rotRand = _rand(vec2(_seed) + origin * vec2(0.01)); + float addedRot = rotRand * 360.0 * prop_variation; + + vec2 dir = buildRot(prop_angle + addedRot) * vec2(-radius, 0); + + // Wrap-aware offset from center + vec2 offsetFromOrigin = mod(uv - origin + 1.0, 1.0); + vec2 centerToUv = offsetFromOrigin - bboxSize * vec2(0.5); + + float grad = distAlongDir(centerToUv, dir); + grad = grad * 0.5 + 0.5; + + return vec4(vec3(grad), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/floodfillv2torandomcolor.cpp b/src/texturelab/libraries/v3/floodfillv2torandomcolor.cpp new file mode 100644 index 00000000..67667d71 --- /dev/null +++ b/src/texturelab/libraries/v3/floodfillv2torandomcolor.cpp @@ -0,0 +1,32 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void FloodFillV2ToRandomColorNode::init() +{ + this->title = "FF To Random Color V2"; + + this->addInput("floodfill"); + + auto source = R""""( + vec4 process(vec2 uv) + { + vec4 data = texture(floodfill, uv); + + if (data.ba == vec2(0.0, 0.0)) + return vec4(0.0, 0.0, 0.0, 1.0); + + // Origin stored directly — use as hash seed + vec2 origin = data.rg; + + vec4 color = vec4(0.0, 0.0, 0.0, 1.0); + color.r = _rand(vec2(_seed) + origin + vec2(1) * vec2(0.01)); + color.g = _rand(vec2(_seed) + origin + vec2(2) * vec2(0.01)); + color.b = _rand(vec2(_seed) + origin + vec2(3) * vec2(0.01)); + + return color; + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/floodfillv2torandomintensity.cpp b/src/texturelab/libraries/v3/floodfillv2torandomintensity.cpp new file mode 100644 index 00000000..2b936071 --- /dev/null +++ b/src/texturelab/libraries/v3/floodfillv2torandomintensity.cpp @@ -0,0 +1,29 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void FloodFillV2ToRandomIntensityNode::init() +{ + this->title = "FF To Random Intensity V2"; + + this->addInput("floodfill"); + + auto source = R""""( + vec4 process(vec2 uv) + { + vec4 data = texture(floodfill, uv); + + if (data.ba == vec2(0.0, 0.0)) + return vec4(0.0, 0.0, 0.0, 1.0); + + vec2 origin = data.rg; + + vec4 color = vec4(0.0, 0.0, 0.0, 1.0); + color.rgb = vec3(_rand(vec2(_seed) + origin + vec2(1) * vec2(0.01))); + + return color; + } + )""""; + + this->setShaderSource(source); +} From 24aa0867369370a52a59761fb2c51d335367a525 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 25 Mar 2026 22:30:51 -0500 Subject: [PATCH 011/164] remove duplicate nodes in v3 --- src/texturelab/libraries/library.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index 084bd8e1..0089436c 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -189,6 +189,15 @@ Library* createLibraryV3() { auto lib = createLibraryV2(); + // Remove V1/V2 nodes that are superseded by V3 equivalents + lib->items.remove("floodfill"); + lib->items.remove("floodfillsampler"); + lib->items.remove("floodfilltobbox"); + lib->items.remove("floodfilltocolor"); + lib->items.remove("floodfilltogradient"); + lib->items.remove("floodfilltorandomcolor"); + lib->items.remove("floodfilltorandomintensity"); + // V3 NODES lib->addNode("bevelv2", "Bevel V2", ":nodes/bevel.png"); lib->addNode("ambientocclusion", "Ambient Occlusion", From 284422b0b2b0561710c421b91b2b3953749c79aa Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 26 Mar 2026 02:26:04 -0500 Subject: [PATCH 012/164] fix middle mouse panning for now --- src/nodegraph/nodegraph.cpp | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/nodegraph/nodegraph.cpp b/src/nodegraph/nodegraph.cpp index f71c4746..5698895d 100644 --- a/src/nodegraph/nodegraph.cpp +++ b/src/nodegraph/nodegraph.cpp @@ -80,6 +80,7 @@ void NodeGraph::setNodeGraphScene(const ScenePtr& scene) } this->_scene = scene; + scene->setSceneRect(-100000, -100000, 200000, 200000); this->setScene(scene.data()); // handle scene's events from within the view @@ -168,21 +169,22 @@ void NodeGraph::keyReleaseEvent(QKeyEvent* event) void NodeGraph::mousePressEvent(QMouseEvent* event) { - if (event->button() == Qt::MiddleButton && - scene()->mouseGrabberItem() == nullptr) { - _clickPos = mapToScene(event->pos()); + if (event->button() == Qt::MiddleButton) { + _clickPos = event->pos(); setDragMode(QGraphicsView::NoDrag); + return; } QGraphicsView::mousePressEvent(event); } void NodeGraph::mouseMoveEvent(QMouseEvent* event) { - - if (event->buttons() == Qt::MiddleButton) { - QPointF difference = _clickPos - mapToScene(event->pos()); - setSceneRect(sceneRect().translated(difference.x(), difference.y())); - _clickPos = mapToScene(event->pos()); // Update reference point to maintain coordinate consistency + if (event->buttons() & Qt::MiddleButton) { + QPointF delta = event->pos() - _clickPos; + horizontalScrollBar()->setValue(horizontalScrollBar()->value() - delta.x()); + verticalScrollBar()->setValue(verticalScrollBar()->value() - delta.y()); + _clickPos = event->pos(); + return; } QGraphicsView::mouseMoveEvent(event); } From 85c265014e39ac0edc1049d7600fd0b39b7de471 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 28 Mar 2026 19:25:29 -0500 Subject: [PATCH 013/164] add curve node, prop and widget --- src/texturelab/CMakeLists.txt | 5 + src/texturelab/assets.qrc | 1 + src/texturelab/curve.cpp | 265 ++++++++++ src/texturelab/curve.h | 55 ++ src/texturelab/graphics/noderenderer.cpp | 52 ++ src/texturelab/graphics/noderenderer.h | 1 + src/texturelab/graphics/renderworker.cpp | 30 ++ src/texturelab/libraries/library.cpp | 2 + src/texturelab/libraries/libv3.h | 1 + src/texturelab/libraries/v3/curvenode.cpp | 19 + src/texturelab/libraries/v3/curvenode.h | 8 + src/texturelab/models.cpp | 13 + src/texturelab/models.h | 3 + src/texturelab/props.h | 48 +- .../widgets/properties/curvepropwidget.cpp | 499 ++++++++++++++++++ .../widgets/properties/curvepropwidget.h | 78 +++ .../widgets/properties/propertieswidget.cpp | 16 + 17 files changed, 1095 insertions(+), 1 deletion(-) create mode 100644 src/texturelab/curve.cpp create mode 100644 src/texturelab/curve.h create mode 100644 src/texturelab/libraries/v3/curvenode.cpp create mode 100644 src/texturelab/libraries/v3/curvenode.h create mode 100644 src/texturelab/widgets/properties/curvepropwidget.cpp create mode 100644 src/texturelab/widgets/properties/curvepropwidget.h diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index c1c0a9d9..bb682454 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -101,6 +101,7 @@ set(LIBRARYV2 set(LIBRARYV3 ./libraries/v3/ambientocclusion.cpp ./libraries/v3/bevelv2.cpp + ./libraries/v3/curvenode.cpp ./libraries/v3/curvature.cpp ./libraries/v3/maskedblur.cpp ./libraries/v3/rays.cpp @@ -121,6 +122,8 @@ set(PROJECT_SOURCES ./exporter.h ./exporter.cpp ./utils.h + ./curve.h + ./curve.cpp ./models.h ./models.cpp ./project.h @@ -141,6 +144,8 @@ set(PROJECT_SOURCES ./widgets/properties/propertieswidget.cpp ./widgets/properties/propwidgets.h ./widgets/properties/propwidgets.cpp + ./widgets/properties/curvepropwidget.h + ./widgets/properties/curvepropwidget.cpp ./widgets/view2dwidget.h ./widgets/view2dwidget.cpp ./widgets/view3dwidget.h diff --git a/src/texturelab/assets.qrc b/src/texturelab/assets.qrc index 4d36f814..efbca3da 100644 --- a/src/texturelab/assets.qrc +++ b/src/texturelab/assets.qrc @@ -83,6 +83,7 @@ ../../public/assets/nodes/valuenoisefractalsum.png + ../../public/assets/nodes/curve.png ../../public/assets/nodes/frame.png diff --git a/src/texturelab/curve.cpp b/src/texturelab/curve.cpp new file mode 100644 index 00000000..49ae1247 --- /dev/null +++ b/src/texturelab/curve.cpp @@ -0,0 +1,265 @@ +#include "curve.h" + +#include +#include + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static float clamp01(float v) { return qBound(0.0f, v, 1.0f); } + +static float snap(float v) +{ + return qRound(v * 1000.0f) / 1000.0f; +} + +float Curve::cubicBez(float p0, float p1, float p2, float p3, float t) +{ + float mt = 1.0f - t; + return mt*mt*mt*p0 + 3.0f*mt*mt*t*p1 + 3.0f*mt*t*t*p2 + t*t*t*p3; +} + +float Curve::cubicBezD(float p0, float p1, float p2, float p3, float t) +{ + float mt = 1.0f - t; + return 3.0f * (mt*mt*(p1-p0) + 2.0f*mt*t*(p2-p1) + t*t*(p3-p2)); +} + +// --------------------------------------------------------------------------- +// Curve +// --------------------------------------------------------------------------- + +Curve::Curve() +{ + CurvePoint p0; + p0.x = 0.0f; p0.y = 0.0f; + p0.lx = -0.15f; p0.ly = 0.0f; + p0.rx = 0.15f; p0.ry = 0.0f; + p0.smooth = true; + + CurvePoint p1; + p1.x = 1.0f; p1.y = 1.0f; + p1.lx = -0.15f; p1.ly = 0.0f; + p1.rx = 0.15f; p1.ry = 0.0f; + p1.smooth = true; + + points.append(p0); + points.append(p1); +} + +float Curve::derivAt(float x) const +{ + // Find which segment contains x and compute dy/dx numerically + const float eps = 0.001f; + float x1 = qBound(0.0f, x - eps, 1.0f); + float x2 = qBound(0.0f, x + eps, 1.0f); + + // Evaluate y at x1 and x2 by Newton-Raphson on each segment + auto evalY = [&](float tx) -> float { + if (points.size() < 2) return tx; + int seg = points.size() - 2; + for (int i = 0; i < points.size() - 1; i++) { + if (tx <= points[i + 1].x) { seg = i; break; } + } + const CurvePoint& a = points[seg]; + const CurvePoint& b = points[seg + 1]; + float P0x = a.x, P0y = a.y; + float P1x = a.x + a.rx, P1y = a.y + a.ry; + float P2x = b.x + b.lx, P2y = b.y + b.ly; + float P3x = b.x, P3y = b.y; + + float t = qBound(0.0f, (tx - P0x) / qMax(P3x - P0x, 1e-5f), 1.0f); + for (int i = 0; i < 8; i++) { + float bx = cubicBez(P0x, P1x, P2x, P3x, t); + float dbx = cubicBezD(P0x, P1x, P2x, P3x, t); + if (qAbs(dbx) > 1e-6f) t -= (bx - tx) / dbx; + t = qBound(0.0f, t, 1.0f); + } + return cubicBez(P0y, P1y, P2y, P3y, t); + }; + + float dy = evalY(x2) - evalY(x1); + float dx = x2 - x1; + return (dx > 1e-6f) ? dy / dx : 1.0f; +} + +void Curve::addPoint(float x, float y) +{ + if (points.size() >= CURVE_MAX_POINTS) return; + + x = snap(clamp01(x)); + y = snap(clamp01(y)); + + // Find insert position + int insertPos = points.size(); + for (int i = 0; i < points.size(); i++) { + if (points[i].x >= x) { insertPos = i; break; } + } + + CurvePoint pt; + pt.x = x; + pt.y = y; + pt.smooth = true; + + // Auto-tangent: set handles tangent to existing curve at this x + float slope = derivAt(x); + float handleLen = 0.1f; + pt.rx = handleLen; + pt.ry = slope * handleLen; + pt.lx = -handleLen; + pt.ly = -slope * handleLen; + + points.insert(insertPos, pt); + clampHandles(insertPos); +} + +void Curve::removePoint(int index) +{ + if (index <= 0 || index >= points.size() - 1) return; + points.removeAt(index); +} + +void Curve::clampHandles(int index) +{ + if (index < 0 || index >= points.size()) return; + CurvePoint& pt = points[index]; + + // rx must be >= 0 + pt.rx = qMax(pt.rx, 0.0f); + // lx must be <= 0 + pt.lx = qMin(pt.lx, 0.0f); + + // Right handle absolute x must not exceed next anchor x + if (index < points.size() - 1) { + float maxRx = points[index + 1].x - pt.x; + if (pt.rx > maxRx) { + // Scale down proportionally + float scale = (maxRx > 1e-6f) ? maxRx / pt.rx : 0.0f; + pt.rx *= scale; + pt.ry *= scale; + } + } + + // Left handle absolute x must not go below previous anchor x + if (index > 0) { + float minLx = points[index - 1].x - pt.x; // negative + if (pt.lx < minLx) { + float scale = (qAbs(minLx) > 1e-6f) ? minLx / pt.lx : 0.0f; + pt.lx *= scale; + pt.ly *= scale; + } + } +} + +void Curve::moveAnchor(int index, float x, float y) +{ + if (index < 0 || index >= points.size()) return; + CurvePoint& pt = points[index]; + + y = snap(clamp01(y)); + + if (index == 0) { + // x locked + pt.y = y; + } else if (index == points.size() - 1) { + // x locked + pt.y = y; + } else { + float minX = points[index - 1].x + 0.005f; + float maxX = points[index + 1].x - 0.005f; + pt.x = snap(qBound(minX, x, maxX)); + pt.y = y; + } + + clampHandles(index); + if (index > 0) clampHandles(index - 1); + if (index < points.size() - 1) clampHandles(index + 1); +} + +void Curve::moveHandle(int index, bool isLeft, float dx, float dy) +{ + if (index < 0 || index >= points.size()) return; + CurvePoint& pt = points[index]; + + if (isLeft) { + pt.lx = snap(pt.lx + dx); + pt.ly = snap(pt.ly + dy); + if (pt.smooth) { + // Mirror to right handle (same length, opposite direction) + float len = qSqrt(pt.lx*pt.lx + pt.ly*pt.ly); + if (len > 1e-6f) { + float rightLen = qSqrt(pt.rx*pt.rx + pt.ry*pt.ry); + pt.rx = (-pt.lx / len) * rightLen; + pt.ry = (-pt.ly / len) * rightLen; + } + } + } else { + pt.rx = snap(pt.rx + dx); + pt.ry = snap(pt.ry + dy); + if (pt.smooth) { + float len = qSqrt(pt.rx*pt.rx + pt.ry*pt.ry); + if (len > 1e-6f) { + float leftLen = qSqrt(pt.lx*pt.lx + pt.ly*pt.ly); + pt.lx = (-pt.rx / len) * leftLen; + pt.ly = (-pt.ry / len) * leftLen; + } + } + } + + clampHandles(index); +} + +QJsonObject Curve::toJson() const +{ + QJsonArray arr; + for (const auto& pt : points) { + QJsonObject obj; + obj["x"] = (double)pt.x; + obj["y"] = (double)pt.y; + obj["lx"] = (double)pt.lx; + obj["ly"] = (double)pt.ly; + obj["rx"] = (double)pt.rx; + obj["ry"] = (double)pt.ry; + obj["smooth"] = pt.smooth; + arr.append(obj); + } + QJsonObject root; + root["points"] = arr; + return root; +} + +Curve Curve::fromJson(const QJsonObject& obj) +{ + Curve curve; + if (!obj.contains("points") || !obj["points"].isArray()) return curve; + + QJsonArray arr = obj["points"].toArray(); + if (arr.size() < 2) return curve; + + curve.points.clear(); + for (const auto& val : arr) { + auto o = val.toObject(); + CurvePoint pt; + pt.x = clamp01((float)o["x"].toDouble()); + pt.y = clamp01((float)o["y"].toDouble()); + pt.lx = qMin((float)o["lx"].toDouble(), 0.0f); + pt.ly = (float)o["ly"].toDouble(); + pt.rx = qMax((float)o["rx"].toDouble(), 0.0f); + pt.ry = (float)o["ry"].toDouble(); + pt.smooth = o["smooth"].toBool(true); + curve.points.append(pt); + } + + // Sort by x, enforce minimum 2 points + std::sort(curve.points.begin(), curve.points.end(), + [](const CurvePoint& a, const CurvePoint& b) { return a.x < b.x; }); + + if (curve.points.size() < 2) return Curve(); // fallback to identity + + // Lock first/last x + curve.points.first().x = 0.0f; + curve.points.last().x = 1.0f; + + return curve; +} diff --git a/src/texturelab/curve.h b/src/texturelab/curve.h new file mode 100644 index 00000000..9933f108 --- /dev/null +++ b/src/texturelab/curve.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include + +constexpr int CURVE_MAX_POINTS = 32; + +struct CurvePoint { + float x = 0.0f, y = 0.0f; + float lx = -0.15f, ly = 0.0f; // left handle offset (lx <= 0) + float rx = 0.15f, ry = 0.0f; // right handle offset (rx >= 0) + bool smooth = true; // true: handles mirror through anchor +}; + +class Curve { +public: + QVector points; // always sorted by x; minimum 2 points + + // Default: linear identity — (0,0) to (1,1) with horizontal handles + Curve(); + + // Insert sorted by x. Auto-computes handles tangent to the existing + // curve at that x so the visible shape is preserved. + void addPoint(float x, float y); + + // Remove by index. No-op for index 0 or last. + void removePoint(int index); + + // Move anchor. Interior: x clamped between neighbours. + // First/last: x locked at 0/1, only y moves. + // If smooth, handles rotate to stay mirrored. + void moveAnchor(int index, float x, float y); + + // Move one handle by a delta. If smooth=true, opposite handle mirrors. + void moveHandle(int index, bool isLeft, float dx, float dy); + + QJsonObject toJson() const; + static Curve fromJson(const QJsonObject& obj); + +private: + // Evaluate the derivative dy/dx at a given x using finite differences, + // used for auto-tangent on addPoint. + float derivAt(float x) const; + + // Cubic bezier helper: evaluate B(t) for one component + static float cubicBez(float p0, float p1, float p2, float p3, float t); + static float cubicBezD(float p0, float p1, float p2, float p3, float t); + + // Clamp handle offsets to maintain x-monotonicity constraints + void clampHandles(int index); +}; + +Q_DECLARE_METATYPE(Curve) diff --git a/src/texturelab/graphics/noderenderer.cpp b/src/texturelab/graphics/noderenderer.cpp index 7eb23274..4700a1b8 100644 --- a/src/texturelab/graphics/noderenderer.cpp +++ b/src/texturelab/graphics/noderenderer.cpp @@ -124,6 +124,7 @@ GLuint RenderResourceCache::compileNodeShader( return shaderCache[key]->programId(); QString fSource = fragmentPreamble() + randomLib() + gradientLib() + + curveLib() + generateInputDeclarations(inputNames) + generatePropDeclarations(propTypes) + "#line 0\n" + processSource; @@ -163,6 +164,7 @@ QString RenderResourceCache::fragmentPreamble() in vec2 v_texCoord; #define GRADIENT_MAX_POINTS 32 + #define CURVE_MAX_POINTS 32 vec4 process(vec2 uv); void initRandom(); @@ -315,6 +317,53 @@ QString RenderResourceCache::gradientLib() )""""; } +QString RenderResourceCache::curveLib() +{ + return R""""( + struct Curve { + int numPoints; + vec2 anchors[CURVE_MAX_POINTS]; + vec2 handleR[CURVE_MAX_POINTS]; + vec2 handleL[CURVE_MAX_POINTS]; + }; + + float _cubicBez(float p0, float p1, float p2, float p3, float t) { + float mt = 1.0 - t; + return mt*mt*mt*p0 + 3.0*mt*mt*t*p1 + 3.0*mt*t*t*p2 + t*t*t*p3; + } + + float _cubicBezD(float p0, float p1, float p2, float p3, float t) { + float mt = 1.0 - t; + return 3.0 * (mt*mt*(p1-p0) + 2.0*mt*t*(p2-p1) + t*t*(p3-p2)); + } + + float evalCurve(Curve c, float x) { + // Find segment: last anchor whose x <= input x + int seg = c.numPoints - 2; + for (int i = 0; i < CURVE_MAX_POINTS - 1; i++) { + if (i >= c.numPoints - 1) break; + if (x <= c.anchors[i + 1].x) { seg = i; break; } + } + + vec2 P0 = c.anchors[seg]; + vec2 P1 = c.handleR[seg]; + vec2 P2 = c.handleL[seg + 1]; + vec2 P3 = c.anchors[seg + 1]; + + // Newton-Raphson: solve Bx(t) = x (8 iterations, constant bound) + float t = clamp((x - P0.x) / max(P3.x - P0.x, 1e-5), 0.0, 1.0); + for (int i = 0; i < 8; i++) { + float bx = _cubicBez(P0.x, P1.x, P2.x, P3.x, t); + float dbx = _cubicBezD(P0.x, P1.x, P2.x, P3.x, t); + t -= (abs(dbx) > 1e-6) ? (bx - x) / dbx : 0.0; + t = clamp(t, 0.0, 1.0); + } + + return _cubicBez(P0.y, P1.y, P2.y, P3.y, t); + } + )""""; +} + QString RenderResourceCache::generateInputDeclarations( const QStringList& inputNames) { @@ -356,6 +405,9 @@ QString RenderResourceCache::generatePropDeclarations( case PropType::Image: code += "uniform sampler2D prop_" + name + ";\n"; break; + case PropType::Curve: + code += "uniform Curve prop_" + name + ";\n"; + break; default: break; } diff --git a/src/texturelab/graphics/noderenderer.h b/src/texturelab/graphics/noderenderer.h index f558cf9f..14094dc8 100644 --- a/src/texturelab/graphics/noderenderer.h +++ b/src/texturelab/graphics/noderenderer.h @@ -78,6 +78,7 @@ class RenderResourceCache { static QString fragmentPreamble(); static QString randomLib(); static QString gradientLib(); + static QString curveLib(); static QString generateInputDeclarations(const QStringList& inputNames); static QString generatePropDeclarations( const QList>& propTypes); diff --git a/src/texturelab/graphics/renderworker.cpp b/src/texturelab/graphics/renderworker.cpp index 126c1170..d777d58d 100644 --- a/src/texturelab/graphics/renderworker.cpp +++ b/src/texturelab/graphics/renderworker.cpp @@ -1,5 +1,6 @@ #include "renderworker.h" #include "../models.h" +#include "../curve.h" #include "gradient.h" #include "texturerenderer.h" #include @@ -439,6 +440,35 @@ void RenderWorker::renderSinglePass(const RenderCommand& command) texIndex++; } } break; + case PropType::Curve: { + auto curve = prop.value.value(); + int nPoints = qMin((int)curve.points.size(), CURVE_MAX_POINTS); + + gl->glUniform1i( + gl->glGetUniformLocation(command.shaderId, + (propCString + ".numPoints").c_str()), + nPoints); + + for (int i = 0; i < nPoints; i++) { + const auto& pt = curve.points[i]; + std::string idx = "[" + std::to_string(i) + "]"; + + gl->glUniform2f( + gl->glGetUniformLocation(command.shaderId, + (propCString + ".anchors" + idx).c_str()), + pt.x, pt.y); + + gl->glUniform2f( + gl->glGetUniformLocation(command.shaderId, + (propCString + ".handleR" + idx).c_str()), + pt.x + pt.rx, pt.y + pt.ry); + + gl->glUniform2f( + gl->glGetUniformLocation(command.shaderId, + (propCString + ".handleL" + idx).c_str()), + pt.x + pt.lx, pt.y + pt.ly); + } + } break; } } diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index 0089436c..522418b1 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -228,5 +228,7 @@ Library* createLibraryV3() "FF Sampler V2", ":nodes/floodfillsampler.png"); + lib->addNode("curve", "Curve", ":nodes/curve.png"); + return lib; } \ No newline at end of file diff --git a/src/texturelab/libraries/libv3.h b/src/texturelab/libraries/libv3.h index 02870604..38f0de3c 100644 --- a/src/texturelab/libraries/libv3.h +++ b/src/texturelab/libraries/libv3.h @@ -1,6 +1,7 @@ #pragma once #include "../models.h" +#include "v3/curvenode.h" #include class BevelV2Node : public TextureNode { diff --git a/src/texturelab/libraries/v3/curvenode.cpp b/src/texturelab/libraries/v3/curvenode.cpp new file mode 100644 index 00000000..5bf883d7 --- /dev/null +++ b/src/texturelab/libraries/v3/curvenode.cpp @@ -0,0 +1,19 @@ +#include "curvenode.h" + +void CurveNode::init() +{ + this->title = "Curve"; + this->addInput("image"); + this->addCurveProp("curve", "Curve"); + + auto source = R""""( + vec4 process(vec2 uv) { + vec4 col = texture(image, uv); + float gray = (col.r + col.g + col.b) * 0.3333333; + float mapped = evalCurve(prop_curve, gray); + return vec4(vec3(mapped), col.a); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/curvenode.h b/src/texturelab/libraries/v3/curvenode.h new file mode 100644 index 00000000..639f1763 --- /dev/null +++ b/src/texturelab/libraries/v3/curvenode.h @@ -0,0 +1,8 @@ +#pragma once + +#include "../../models.h" + +class CurveNode : public TextureNode { +public: + void init() override; +}; diff --git a/src/texturelab/models.cpp b/src/texturelab/models.cpp index a81b6bb4..36b30383 100644 --- a/src/texturelab/models.cpp +++ b/src/texturelab/models.cpp @@ -286,5 +286,18 @@ ImageProp* TextureNode::addImageProp(const QString& name, props[name] = prop; + return prop; +} + +CurveProp* TextureNode::addCurveProp(const QString& name, + const QString& displayName) +{ + auto prop = new CurveProp(); + prop->name = name; + prop->displayName = displayName; + prop->order = props.size(); + + props[name] = prop; + return prop; } \ No newline at end of file diff --git a/src/texturelab/models.h b/src/texturelab/models.h index 2b181981..41ceccdc 100644 --- a/src/texturelab/models.h +++ b/src/texturelab/models.h @@ -41,6 +41,7 @@ class ColorProp; class StringProp; class GradientProp; class ImageProp; +class CurveProp; enum class PackageFileType { Texture, Image }; @@ -189,6 +190,8 @@ class TextureNode : public QEnableSharedFromThis { const Gradient& defaultVal); ImageProp* addImageProp(const QString& name, const QString& displayName); + + CurveProp* addCurveProp(const QString& name, const QString& displayName); }; class Comment : public QEnableSharedFromThis { diff --git a/src/texturelab/props.h b/src/texturelab/props.h index 42adf055..1cf3f9c6 100644 --- a/src/texturelab/props.h +++ b/src/texturelab/props.h @@ -1,5 +1,6 @@ #pragma once +#include "curve.h" #include "../colorpicker/gradient.h" #include #include @@ -30,7 +31,8 @@ class PropType { Enum, String, Gradient, - Image + Image, + Curve }; static QString toString(Value propType); @@ -506,6 +508,50 @@ class GradientProp : public Prop { } }; +class CurveProp : public Prop { +public: + Curve value; // default: linear identity + + CurveProp() : Prop() { type = PropType::Curve; } + + Prop* clone() const override + { + auto* copy = new CurveProp(*this); + copy->group = nullptr; + return copy; + } + + QVariant getValue() override { return QVariant::fromValue(value); } + + void setValue(QVariant val) override { value = val.value(); } + + QJsonObject toJson() override + { + auto obj = Prop::toJson(); + obj["value"] = value.toJson(); + return obj; + } + + void fromJson(const QJsonObject& obj) override + { + Prop::fromJson(obj); + if (obj.contains("value") && obj["value"].isObject()) + value = Curve::fromJson(obj["value"].toObject()); + else + value = Curve(); // fallback to linear identity + } + + QJsonValue toJsonValue() override { return value.toJson(); } + + void fromJsonValue(const QJsonValue& val) override + { + if (val.isObject()) + value = Curve::fromJson(val.toObject()); + else + value = Curve(); + } +}; + class ImageProp : public Prop { public: diff --git a/src/texturelab/widgets/properties/curvepropwidget.cpp b/src/texturelab/widgets/properties/curvepropwidget.cpp new file mode 100644 index 00000000..f6226efa --- /dev/null +++ b/src/texturelab/widgets/properties/curvepropwidget.cpp @@ -0,0 +1,499 @@ +#include "curvepropwidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +// ============================================================================ +// Colour constants +// ============================================================================ + +static const QColor COL_BG { 0x1a, 0x1a, 0x1a }; +static const QColor COL_GRID { 0x25, 0x25, 0x25 }; +static const QColor COL_IDENTITY { 0x30, 0x30, 0x30 }; +static const QColor COL_CURVE { 0xe0, 0xe0, 0xe0 }; +static const QColor COL_ANCHOR_DEF { 0x88, 0x88, 0x88 }; +static const QColor COL_ANCHOR_HOV { 0xff, 0xff, 0xff }; +static const QColor COL_ANCHOR_SEL { 0x4a, 0x9e, 0xff }; +static const QColor COL_HANDLE_LINE{ 0x55, 0x55, 0x55 }; +static const QColor COL_HANDLE_DOT { 0x88, 0x88, 0x88 }; +static const QColor COL_HANDLE_HOV { 0xcc, 0xcc, 0xcc }; +static const QColor COL_HANDLE_COR { 0xff, 0x99, 0x44 }; // corner (broken) mode + +static constexpr int CANVAS_PAD = 8; // px padding inside canvas +static constexpr float ANCHOR_R = 5.0f; +static constexpr float ANCHOR_R_HL = 6.0f; +static constexpr float HANDLE_R = 3.0f; + +// ============================================================================ +// CurveCanvas +// ============================================================================ + +CurveCanvas::CurveCanvas(QWidget* parent) : QWidget(parent) +{ + setMouseTracking(true); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + setMinimumSize(200, 200); + // Keep square + QSizePolicy sp = sizePolicy(); + sp.setHeightForWidth(true); + setSizePolicy(sp); +} + +int CurveCanvas::heightForWidth(int w) const { return w; } +bool CurveCanvas::hasHeightForWidth() const { return true; } + +void CurveCanvas::setCurve(const Curve& c) +{ + curve = c; + update(); +} + +// --------------------------------------------------------------------------- +// Coordinate helpers +// --------------------------------------------------------------------------- + +QPointF CurveCanvas::toWidget(float x, float y) const +{ + int pad = CANVAS_PAD; + float W = width() - 2 * pad; + float H = height() - 2 * pad; + // y is flipped: y=0 → bottom, y=1 → top + return QPointF(pad + x * W, pad + (1.0f - y) * H); +} + +QPointF CurveCanvas::toCurveSpace(QPointF p) const +{ + int pad = CANVAS_PAD; + float W = width() - 2 * pad; + float H = height() - 2 * pad; + float x = (p.x() - pad) / W; + float y = 1.0f - (p.y() - pad) / H; + return QPointF(qBound(0.0, x, 1.0), qBound(0.0, y, 1.0)); +} + +// --------------------------------------------------------------------------- +// Hit testing +// --------------------------------------------------------------------------- + +int CurveCanvas::hitTestAnchor(QPointF pos, float radiusPx) const +{ + for (int i = 0; i < curve.points.size(); i++) { + QPointF wp = toWidget(curve.points[i].x, curve.points[i].y); + if (QLineF(pos, wp).length() <= radiusPx) + return i; + } + return -1; +} + +int CurveCanvas::hitTestHandle(QPointF pos, bool& outLeft, float radiusPx) const +{ + if (selectedPoint < 0 || selectedPoint >= curve.points.size()) + return -1; + + const CurvePoint& pt = curve.points[selectedPoint]; + + QPointF lhW = toWidget(pt.x + pt.lx, pt.y + pt.ly); + QPointF rhW = toWidget(pt.x + pt.rx, pt.y + pt.ry); + + if (QLineF(pos, lhW).length() <= radiusPx) { outLeft = true; return selectedPoint; } + if (QLineF(pos, rhW).length() <= radiusPx) { outLeft = false; return selectedPoint; } + return -1; +} + +int CurveCanvas::hitTestCurvePath(QPointF pos, float tolerancePx) const +{ + if (curve.points.size() < 2) return -1; + + // Sample the curve path and find closest point + const int STEPS = 200; + float minDist = tolerancePx; + int bestSeg = -1; + float bestX = 0.0f; + + for (int i = 0; i < curve.points.size() - 1; i++) { + const CurvePoint& a = curve.points[i]; + const CurvePoint& b = curve.points[i + 1]; + + for (int s = 0; s <= STEPS; s++) { + float t = s / (float)STEPS; + float mt = 1.0f - t; + + float px = mt*mt*mt*a.x + 3*mt*mt*t*(a.x+a.rx) + + 3*mt*t*t*(b.x+b.lx) + t*t*t*b.x; + float py = mt*mt*mt*a.y + 3*mt*mt*t*(a.y+a.ry) + + 3*mt*t*t*(b.y+b.ly) + t*t*t*b.y; + + QPointF wp = toWidget(px, py); + float d = (float)QLineF(pos, wp).length(); + if (d < minDist) { + minDist = d; + bestSeg = i; + bestX = px; + } + } + } + (void)bestX; + return bestSeg; // segment index — caller inserts a point at mouse x +} + +// --------------------------------------------------------------------------- +// Paint +// --------------------------------------------------------------------------- + +void CurveCanvas::paintEvent(QPaintEvent*) +{ + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + + drawGrid(p); + drawIdentityLine(p); + drawCurvePath(p); + drawHandles(p); + drawAnchors(p); +} + +void CurveCanvas::drawGrid(QPainter& p) +{ + p.fillRect(rect(), COL_BG); + + QPen pen(COL_GRID, 1); + p.setPen(pen); + + for (int i = 0; i <= 4; i++) { + float t = i / 4.0f; + QPointF a = toWidget(t, 0.0f); + QPointF b = toWidget(t, 1.0f); + p.drawLine(a, b); + + a = toWidget(0.0f, t); + b = toWidget(1.0f, t); + p.drawLine(a, b); + } +} + +void CurveCanvas::drawIdentityLine(QPainter& p) +{ + QPen pen(COL_IDENTITY, 1, Qt::DashLine); + p.setPen(pen); + p.drawLine(toWidget(0, 0), toWidget(1, 1)); +} + +void CurveCanvas::drawCurvePath(QPainter& p) +{ + if (curve.points.size() < 2) return; + + QPainterPath path; + path.moveTo(toWidget(curve.points[0].x, curve.points[0].y)); + + for (int i = 0; i < curve.points.size() - 1; i++) { + const CurvePoint& a = curve.points[i]; + const CurvePoint& b = curve.points[i + 1]; + + QPointF cp1 = toWidget(a.x + a.rx, a.y + a.ry); + QPointF cp2 = toWidget(b.x + b.lx, b.y + b.ly); + QPointF end = toWidget(b.x, b.y); + path.cubicTo(cp1, cp2, end); + } + + QPen pen(COL_CURVE, 1.5f); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + p.drawPath(path); +} + +void CurveCanvas::drawHandles(QPainter& p) +{ + if (selectedPoint < 0 || selectedPoint >= curve.points.size()) return; + + const CurvePoint& pt = curve.points[selectedPoint]; + QPointF anchor = toWidget(pt.x, pt.y); + QPointF lh = toWidget(pt.x + pt.lx, pt.y + pt.ly); + QPointF rh = toWidget(pt.x + pt.rx, pt.y + pt.ry); + + QColor dotColor = pt.smooth ? COL_HANDLE_DOT : COL_HANDLE_COR; + + // Lines from anchor to handles + QPen linePen(COL_HANDLE_LINE, 1); + p.setPen(linePen); + p.drawLine(anchor, lh); + p.drawLine(anchor, rh); + + // Handle dots + auto drawHandle = [&](QPointF pos, bool isHovered) { + QColor c = isHovered ? COL_HANDLE_HOV : dotColor; + p.setPen(QPen(c, 1)); + p.setBrush(Qt::NoBrush); + p.drawEllipse(pos, HANDLE_R, HANDLE_R); + }; + + bool lhHovered = (hoveredHandle == selectedPoint && hoveredHandleLeft); + bool rhHovered = (hoveredHandle == selectedPoint && !hoveredHandleLeft); + drawHandle(lh, lhHovered); + drawHandle(rh, rhHovered); +} + +void CurveCanvas::drawAnchors(QPainter& p) +{ + for (int i = 0; i < curve.points.size(); i++) { + const CurvePoint& pt = curve.points[i]; + QPointF wp = toWidget(pt.x, pt.y); + + float r; + QColor fill; + + if (i == selectedPoint) { + r = ANCHOR_R_HL; + fill = COL_ANCHOR_SEL; + // ring + p.setPen(QPen(COL_ANCHOR_SEL, 1)); + p.setBrush(Qt::NoBrush); + p.drawEllipse(wp, r + 2, r + 2); + } else if (i == hoveredPoint) { + r = ANCHOR_R_HL; + fill = COL_ANCHOR_HOV; + } else { + r = ANCHOR_R; + fill = COL_ANCHOR_DEF; + } + + p.setPen(Qt::NoPen); + p.setBrush(fill); + p.drawEllipse(wp, r, r); + } +} + +// --------------------------------------------------------------------------- +// Mouse events +// --------------------------------------------------------------------------- + +void CurveCanvas::mousePressEvent(QMouseEvent* event) +{ + altHeld = event->modifiers() & Qt::AltModifier; + + if (event->button() == Qt::LeftButton) { + QPointF pos = event->position(); + + // 1. Hit test handle (only when a point is selected) + bool handleLeft = false; + int hi = hitTestHandle(pos, handleLeft); + if (hi >= 0) { + dragTarget = handleLeft ? CurveDragTarget::LeftHandle + : CurveDragTarget::RightHandle; + dragIndex = hi; + dragHandleLeft = handleLeft; + setCursor(Qt::ClosedHandCursor); + return; + } + + // 2. Hit test anchor + int ai = hitTestAnchor(pos); + if (ai >= 0) { + selectedPoint = ai; + dragTarget = CurveDragTarget::Anchor; + dragIndex = ai; + setCursor(Qt::ClosedHandCursor); + update(); + return; + } + + // 3. Hit test curve path → add point on curve + QPointF cs = toCurveSpace(pos); + int pathSeg = hitTestCurvePath(pos); + if (pathSeg >= 0) { + // Snap y to existing curve at this x + curve.addPoint((float)cs.x(), (float)cs.y()); + selectedPoint = -1; + // Find the newly inserted point + for (int i = 0; i < curve.points.size(); i++) { + if (qAbs(curve.points[i].x - (float)cs.x()) < 0.01f) { + selectedPoint = i; + break; + } + } + emit curveChanged(curve); + update(); + return; + } + + // 4. Empty area → add new point + if (curve.points.size() < CURVE_MAX_POINTS) { + curve.addPoint((float)cs.x(), (float)cs.y()); + selectedPoint = -1; + for (int i = 0; i < curve.points.size(); i++) { + if (qAbs(curve.points[i].x - (float)cs.x()) < 0.01f) { + selectedPoint = i; + break; + } + } + emit curveChanged(curve); + update(); + } + } else if (event->button() == Qt::LeftButton) { + // Deselect on background click (handled above via fall-through) + selectedPoint = -1; + update(); + } +} + +void CurveCanvas::mouseMoveEvent(QMouseEvent* event) +{ + QPointF pos = event->position(); + altHeld = event->modifiers() & Qt::AltModifier; + + if (dragTarget == CurveDragTarget::Anchor && dragIndex >= 0) { + QPointF cs = toCurveSpace(pos); + curve.moveAnchor(dragIndex, (float)cs.x(), (float)cs.y()); + emit curveChanged(curve); + emit anchorDragging(dragIndex); + update(); + return; + } + + if ((dragTarget == CurveDragTarget::LeftHandle || + dragTarget == CurveDragTarget::RightHandle) && dragIndex >= 0) + { + // Break symmetry if Alt held + if (altHeld) curve.points[dragIndex].smooth = false; + + QPointF cs = toCurveSpace(pos); + const CurvePoint& pt = curve.points[dragIndex]; + float dx = (float)cs.x() - pt.x; + float dy = (float)cs.y() - pt.y; + + bool isLeft = (dragTarget == CurveDragTarget::LeftHandle); + // Compute delta from current handle position + float curHx = isLeft ? pt.lx : pt.rx; + float curHy = isLeft ? pt.ly : pt.ry; + curve.moveHandle(dragIndex, isLeft, dx - curHx, dy - curHy); + emit curveChanged(curve); + update(); + return; + } + + // Hover detection + int prevHovAnchor = hoveredPoint; + int prevHovHandle = hoveredHandle; + hoveredPoint = hitTestAnchor(pos); + bool hl = false; + hoveredHandle = hitTestHandle(pos, hl); + hoveredHandleLeft = hl; + + if (hoveredPoint >= 0 || hoveredHandle >= 0) + setCursor(Qt::SizeAllCursor); + else + setCursor(Qt::CrossCursor); + + if (hoveredPoint != prevHovAnchor || hoveredHandle != prevHovHandle) + update(); +} + +void CurveCanvas::mouseReleaseEvent(QMouseEvent* event) +{ + if (event->button() == Qt::LeftButton) { + if (dragTarget != CurveDragTarget::None) + emit dragEnded(); + dragTarget = CurveDragTarget::None; + dragIndex = -1; + setCursor(Qt::CrossCursor); + } +} + +void CurveCanvas::contextMenuEvent(QContextMenuEvent* event) +{ + int ai = hitTestAnchor(event->pos()); + if (ai < 0) return; + + QMenu menu(this); + QAction* removeAct = menu.addAction("Remove Point"); + removeAct->setEnabled(ai > 0 && ai < curve.points.size() - 1); + + QAction* chosen = menu.exec(event->globalPos()); + if (chosen == removeAct) { + curve.removePoint(ai); + if (selectedPoint == ai) selectedPoint = -1; + else if (selectedPoint > ai) selectedPoint--; + emit curveChanged(curve); + update(); + } +} + +void CurveCanvas::leaveEvent(QEvent*) +{ + hoveredPoint = -1; + hoveredHandle = -1; + update(); +} + +// ============================================================================ +// CurvePropWidget +// ============================================================================ + +CurvePropWidget::CurvePropWidget(CurveProp* prop, QWidget* parent) + : QWidget(parent), prop(prop) +{ + auto* vLayout = new QVBoxLayout(this); + vLayout->setContentsMargins(0, 0, 0, 4); + vLayout->setSpacing(4); + + // Label row with Reset button + auto* headerRow = new QHBoxLayout(); + headerRow->setContentsMargins(0, 0, 0, 0); + + auto* label = new QLabel(prop->displayName, this); + resetBtn = new QPushButton("Reset", this); + resetBtn->setFixedWidth(50); + resetBtn->setFixedHeight(20); + resetBtn->setStyleSheet("font-size: 10px;"); + + headerRow->addWidget(label); + headerRow->addStretch(); + headerRow->addWidget(resetBtn); + vLayout->addLayout(headerRow); + + // Canvas + canvas = new CurveCanvas(this); + canvas->setCurve(prop->value); + vLayout->addWidget(canvas); + + // Readout label + readout = new QLabel(this); + readout->setStyleSheet("color: #888; font-size: 10px;"); + readout->setVisible(false); + vLayout->addWidget(readout); + + setLayout(vLayout); + + // Connections + connect(canvas, &CurveCanvas::curveChanged, this, [this](const Curve& c) { + this->prop->value = c; + emit valueChanged(c); + }); + + connect(canvas, &CurveCanvas::anchorDragging, this, [this](int idx) { + if (idx >= 0 && idx < this->prop->value.points.size()) { + const CurvePoint& pt = this->prop->value.points[idx]; + readout->setText(QString("In: %1 Out: %2") + .arg(pt.x, 0, 'f', 2) + .arg(pt.y, 0, 'f', 2)); + readout->setVisible(true); + } + }); + + connect(canvas, &CurveCanvas::dragEnded, this, [this]() { + readout->setVisible(false); + }); + + connect(resetBtn, &QPushButton::clicked, this, [this]() { + Curve identity; + this->prop->value = identity; + canvas->setCurve(identity); + readout->setVisible(false); + emit valueChanged(identity); + }); +} diff --git a/src/texturelab/widgets/properties/curvepropwidget.h b/src/texturelab/widgets/properties/curvepropwidget.h new file mode 100644 index 00000000..be93a5e2 --- /dev/null +++ b/src/texturelab/widgets/properties/curvepropwidget.h @@ -0,0 +1,78 @@ +#pragma once + +#include "../../curve.h" +#include "../../props.h" + +#include +#include +#include + +enum class CurveDragTarget { None, Anchor, LeftHandle, RightHandle }; + +class CurveCanvas : public QWidget { + Q_OBJECT +public: + explicit CurveCanvas(QWidget* parent = nullptr); + + void setCurve(const Curve& curve); + const Curve& getCurve() const { return curve; } + + bool hasHeightForWidth() const override; + int heightForWidth(int w) const override; + +signals: + void curveChanged(const Curve& curve); + void anchorDragging(int index); // emits index while dragging anchor + void dragEnded(); + +protected: + void paintEvent(QPaintEvent* event) override; + void mousePressEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + void contextMenuEvent(QContextMenuEvent* event) override; + void leaveEvent(QEvent* event) override; + +private: + Curve curve; + int selectedPoint = -1; + int hoveredPoint = -1; + int hoveredHandle = -1; // index of point whose handle is hovered + bool hoveredHandleLeft = false; + + CurveDragTarget dragTarget = CurveDragTarget::None; + int dragIndex = -1; + bool dragHandleLeft = false; + QPointF dragStartCurve; // curve-space position at drag start + bool altHeld = false; + + QPointF toWidget(float x, float y) const; + QPointF toCurveSpace(QPointF widgetPos) const; + + int hitTestAnchor(QPointF pos, float radiusPx = 8.0f) const; + // Returns point index; sets outLeft = true if left handle hit + int hitTestHandle(QPointF pos, bool& outLeft, float radiusPx = 8.0f) const; + // Returns point index to insert after if cursor is near the path + int hitTestCurvePath(QPointF pos, float tolerancePx = 6.0f) const; + + void drawGrid(QPainter& p); + void drawIdentityLine(QPainter& p); + void drawCurvePath(QPainter& p); + void drawAnchors(QPainter& p); + void drawHandles(QPainter& p); +}; + +class CurvePropWidget : public QWidget { + Q_OBJECT +public: + explicit CurvePropWidget(CurveProp* prop, QWidget* parent = nullptr); + +signals: + void valueChanged(const Curve& curve); + +private: + CurveProp* prop; + CurveCanvas* canvas; + QLabel* readout; + QPushButton* resetBtn; +}; diff --git a/src/texturelab/widgets/properties/propertieswidget.cpp b/src/texturelab/widgets/properties/propertieswidget.cpp index ce088f20..355a9fa7 100644 --- a/src/texturelab/widgets/properties/propertieswidget.cpp +++ b/src/texturelab/widgets/properties/propertieswidget.cpp @@ -1,6 +1,7 @@ #include "propertieswidget.h" #include "../../models.h" #include "../../props.h" +#include "curvepropwidget.h" #include "propwidgets.h" #include @@ -156,6 +157,21 @@ void PropertiesWidget::setSelectedNode(const TextureNodePtr& node) }); layout->addWidget(widget); + } break; + case PropType::Curve: { + auto widget = new CurvePropWidget((CurveProp*)prop); + propWidgets.append(widget); + + connect(widget, &CurvePropWidget::valueChanged, + [=](const Curve& value) { + node->setProp(prop->name, QVariant::fromValue(value)); + project->markNodeAsDirty(node); + + emit propertyUpdated(prop->name, + QVariant::fromValue(value)); + }); + layout->addWidget(widget); + } break; } } From 572d17dd25a603a81478b8f7f384390b490b95d7 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 28 Mar 2026 21:49:19 -0500 Subject: [PATCH 014/164] centralize code generation and node boilerplate code --- src/texturelab/graphics/noderenderer.h | 17 +- src/texturelab/graphics/texturerenderer.cpp | 251 ++------------------ src/texturelab/graphics/texturerenderer.h | 4 - 3 files changed, 24 insertions(+), 248 deletions(-) diff --git a/src/texturelab/graphics/noderenderer.h b/src/texturelab/graphics/noderenderer.h index 14094dc8..81c85fd7 100644 --- a/src/texturelab/graphics/noderenderer.h +++ b/src/texturelab/graphics/noderenderer.h @@ -61,6 +61,15 @@ class RenderResourceCache { // Standard vertex shader source (shared across all node shaders) static QString standardVertexSource(); + // Shader source building blocks — public so other renderers can reuse them + static QString fragmentPreamble(); + static QString randomLib(); + static QString gradientLib(); + static QString curveLib(); + static QString generateInputDeclarations(const QStringList& inputNames); + static QString generatePropDeclarations( + const QList>& propTypes); + private: QOpenGLFunctions_3_2_Core* gl = nullptr; GLuint m_fboId = 0; @@ -74,14 +83,6 @@ class RenderResourceCache { QList texturePool; QMap shaderCache; - - static QString fragmentPreamble(); - static QString randomLib(); - static QString gradientLib(); - static QString curveLib(); - static QString generateInputDeclarations(const QStringList& inputNames); - static QString generatePropDeclarations( - const QList>& propTypes); }; // View into the worker's GL state, passed to renderers at render time. diff --git a/src/texturelab/graphics/texturerenderer.cpp b/src/texturelab/graphics/texturerenderer.cpp index 1bffb3df..ee7cb743 100644 --- a/src/texturelab/graphics/texturerenderer.cpp +++ b/src/texturelab/graphics/texturerenderer.cpp @@ -1,4 +1,5 @@ #include "texturerenderer.h" +#include "noderenderer.h" #include "renderworker.h" // #include "../models.h" @@ -730,50 +731,23 @@ TextureRenderer::buildShaderForNode(const TextureNodePtr& node) QOpenGLShader* fshader = new QOpenGLShader(QOpenGLShader::Fragment); auto program = new QOpenGLShaderProgram; - QString vSource = R""""( - #version 150 core - - //precision highp float; - - in vec3 a_pos; - in vec2 a_texCoord; + // Build input/prop declaration lists for RenderResourceCache helpers + QStringList inputNames = node->inputs; - out vec2 v_texCoord; - - void main() - { - v_texCoord = a_texCoord; - gl_Position = vec4(a_pos,1); - } - )""""; - - QString fSource = R""""( - #version 150 core - //precision highp float; - in vec2 v_texCoord; - - #define GRADIENT_MAX_POINTS 32 - - vec4 process(vec2 uv); - void initRandom(); - - uniform vec2 _textureSize; - - out vec4 fragColor; - - void main() { - initRandom(); - vec4 result = process(v_texCoord); - fragColor = clamp(result, 0.0, 1.0); - } - - )""""; + QList> propTypes; + for (auto prop : node->props) + propTypes.append({prop->name, (int)prop->type}); - fSource = fSource + this->createRandomLib() + this->createGradientLib() + - this->createCodeForInputs(node) + this->createCodeForProps(node) + - "#line 0\n" + node->shaderSource; + QString fSource = RenderResourceCache::fragmentPreamble() + + RenderResourceCache::randomLib() + + RenderResourceCache::gradientLib() + + RenderResourceCache::curveLib() + + RenderResourceCache::generateInputDeclarations(inputNames) + + RenderResourceCache::generatePropDeclarations(propTypes) + + "#line 0\n" + + node->shaderSource; - if (!vshader->compileSourceCode(vSource)) { + if (!vshader->compileSourceCode(RenderResourceCache::standardVertexSource())) { qDebug() << "VERTEX SHADER ERROR"; qDebug() << vshader->log(); } @@ -783,10 +757,7 @@ TextureRenderer::buildShaderForNode(const TextureNodePtr& node) qDebug() << fshader->log(); } - // qDebug() << fSource; - program->removeAllShaders(); - program->addShader(vshader); program->addShader(fshader); @@ -802,196 +773,4 @@ TextureRenderer::buildShaderForNode(const TextureNodePtr& node) ctx->doneCurrent(); return program; -} - -QString TextureRenderer::createRandomLib() -{ - return R""""( - // this offsets the random start (should be a uniform) - uniform float _seed; - // this is the starting number for the rng - // (should be set from the uv coordinates so it's unique per pixel) - vec2 _randomStart; - - // gives a much better distribution at 1 - #define RANDOM_ITERATIONS 1 - - #define HASHSCALE1 443.8975 - #define HASHSCALE3 vec3(443.897, 441.423, 437.195) - #define HASHSCALE4 vec4(443.897, 441.423, 437.195, 444.129) - - // 1 out, 2 in... - float hash12(vec2 p) - { - vec3 p3 = fract(vec3(p.xyx) * HASHSCALE1); - p3 += dot(p3, p3.yzx + 19.19); - return fract((p3.x + p3.y) * p3.z); - } - - /// 2 out, 2 in... - vec2 hash22(vec2 p) - { - vec3 p3 = fract(vec3(p.xyx) * HASHSCALE3); - p3 += dot(p3, p3.yzx+19.19); - return fract((p3.xx+p3.yz)*p3.zy); - - } - - - float _rand(vec2 uv) - { - float a = 0.0; - for (int t = 0; t < RANDOM_ITERATIONS; t++) - { - float v = float(t+1)*.152; - // 0.005 is a good value - vec2 pos = (uv * v); - a += hash12(pos); - } - - return a/float(RANDOM_ITERATIONS); - } - - vec2 _rand2(vec2 uv) - { - vec2 a = vec2(0.0); - for (int t = 0; t < RANDOM_ITERATIONS; t++) - { - float v = float(t+1)*.152; - // 0.005 is a good value - vec2 pos = (uv * v); - a += hash22(pos); - } - - return a/float(RANDOM_ITERATIONS); - } - - float randomFloat(int index) - { - return _rand(_randomStart + vec2(_seed) + vec2(index)); - } - - float randomVec2(int index) - { - return _rand(_randomStart + vec2(_seed) + vec2(index)); - } - - float randomFloat(int index, float start, float end) - { - float r = _rand(_randomStart + vec2(_seed) + vec2(index)); - return start + r*(end-start); - } - - int randomInt(int index, int start, int end) - { - float r = _rand(_randomStart + vec2(_seed) + vec2(index)); - return start + int(r*float(end-start)); - } - - bool randomBool(int index) - { - return _rand(_randomStart + vec2(_seed) + vec2(index)) > 0.5; - } - - void initRandom() - { - _randomStart = v_texCoord; - } - )""""; -} - -QString TextureRenderer::createGradientLib() -{ - return R""""( - struct Gradient { - vec3 colors[GRADIENT_MAX_POINTS]; - float positions[GRADIENT_MAX_POINTS]; - int numPoints; - }; - - // assumes points are sorted - vec3 sampleGradient(vec3 colors[GRADIENT_MAX_POINTS], float positions[GRADIENT_MAX_POINTS], int numPoints, float t) - { - if (numPoints == 0) - return vec3(1,0,0); - - if (numPoints == 1) - return colors[0]; - - // here at least two points are available - if (t <= positions[0]) - return colors[0]; - - int last = numPoints - 1; - if (t >= positions[last]) - return colors[last]; - - // find two points in-between and lerp - - for(int i = 0; i < numPoints-1;i++) { - if (positions[i+1] > t) { - vec3 colorA = colors[i]; - vec3 colorB = colors[i+1]; - - float t1 = positions[i]; - float t2 = positions[i+1]; - - float lerpPos = (t - t1)/(t2 - t1); - return mix(colorA, colorB, lerpPos); - - } - - } - - return vec3(0,0,0); - } - - vec3 sampleGradient(Gradient gradient, float t) - { - return sampleGradient(gradient.colors, gradient.positions, gradient.numPoints, t); - } - )""""; -} -QString TextureRenderer::createCodeForInputs(const TextureNodePtr& node) -{ - QString code = ""; - for (auto input : node->inputs) { - code += "uniform sampler2D " + input + ";\n"; - code += "uniform bool " + input + "_connected;\n"; - } - - return code; -} - -QString TextureRenderer::createCodeForProps(const TextureNodePtr& node) -{ - QString code = ""; - - for (auto prop : node->props) { - switch (prop->type) { - case PropType::Int: - code += "uniform int prop_" + prop->name + ";\n"; - break; - case PropType::Float: - code += "uniform float prop_" + prop->name + ";\n"; - break; - case PropType::Bool: - code += "uniform bool prop_" + prop->name + ";\n"; - break; - case PropType::Enum: - code += "uniform int prop_" + prop->name + ";\n"; - break; - case PropType::Color: - code += "uniform vec4 prop_" + prop->name + ";\n"; - break; - case PropType::Gradient: - code += "uniform Gradient prop_" + prop->name + ";\n"; - break; - case PropType::Image: - code += "uniform sampler2D prop_" + prop->name + ";\n"; - break; - } - } - - return code + "\n"; } \ No newline at end of file diff --git a/src/texturelab/graphics/texturerenderer.h b/src/texturelab/graphics/texturerenderer.h index e30f0181..980fcb76 100644 --- a/src/texturelab/graphics/texturerenderer.h +++ b/src/texturelab/graphics/texturerenderer.h @@ -64,10 +64,6 @@ class TextureRenderer : public QObject { QVector getNodeInputs(const TextureNodePtr& node); TextureNodePtr getNextUpdatableNode() const; QOpenGLShaderProgram* buildShaderForNode(const TextureNodePtr& node); - QString createRandomLib(); - QString createGradientLib(); - QString createCodeForInputs(const TextureNodePtr& node); - QString createCodeForProps(const TextureNodePtr& node); signals: void thumbnailGenerated(const QString& nodeId, GLuint texId, From 36c32ba1f18f9a49ce66c6aabde032430a4e7a7f Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 28 Mar 2026 22:13:38 -0500 Subject: [PATCH 015/164] complete curve featureset --- src/texturelab/curve.cpp | 8 ++++---- src/texturelab/libraries/v3/curvenode.cpp | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/texturelab/curve.cpp b/src/texturelab/curve.cpp index 49ae1247..9df6f5c9 100644 --- a/src/texturelab/curve.cpp +++ b/src/texturelab/curve.cpp @@ -34,14 +34,14 @@ Curve::Curve() { CurvePoint p0; p0.x = 0.0f; p0.y = 0.0f; - p0.lx = -0.15f; p0.ly = 0.0f; - p0.rx = 0.15f; p0.ry = 0.0f; + p0.lx = -0.15f; p0.ly = -0.15f; + p0.rx = 0.15f; p0.ry = 0.15f; p0.smooth = true; CurvePoint p1; p1.x = 1.0f; p1.y = 1.0f; - p1.lx = -0.15f; p1.ly = 0.0f; - p1.rx = 0.15f; p1.ry = 0.0f; + p1.lx = -0.15f; p1.ly = -0.15f; + p1.rx = 0.15f; p1.ry = 0.15f; p1.smooth = true; points.append(p0); diff --git a/src/texturelab/libraries/v3/curvenode.cpp b/src/texturelab/libraries/v3/curvenode.cpp index 5bf883d7..c12e4824 100644 --- a/src/texturelab/libraries/v3/curvenode.cpp +++ b/src/texturelab/libraries/v3/curvenode.cpp @@ -12,6 +12,7 @@ void CurveNode::init() float gray = (col.r + col.g + col.b) * 0.3333333; float mapped = evalCurve(prop_curve, gray); return vec4(vec3(mapped), col.a); + // return vec4(1.0,0.0,0.0,1.0); } )""""; From 3d1b91197384ca2ace64702c34888dbe9936a1d2 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 15 Apr 2026 01:50:42 -0500 Subject: [PATCH 016/164] add blurhq --- src/texturelab/libraries/v3/blurhq.cpp | 146 +++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 src/texturelab/libraries/v3/blurhq.cpp diff --git a/src/texturelab/libraries/v3/blurhq.cpp b/src/texturelab/libraries/v3/blurhq.cpp new file mode 100644 index 00000000..95eb4fb1 --- /dev/null +++ b/src/texturelab/libraries/v3/blurhq.cpp @@ -0,0 +1,146 @@ +#include "../../graphics/noderenderer.h" +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +#include +#include +#include + +// https://learnopengl.com/Advanced-Lighting/Bloom (separated Gaussian in GLSL) +// Wells, "Efficient Synthesis of Gaussian Filters by Cascaded Uniform Filters," +// IEEE PAMI 1986 +// True separated Gaussian blur: two 1D passes (H then V) give O(2N) samples +// vs O(N²) for a 2D kernel — making large-radius blurs practical on the GPU. +// Use in preference to the V2 Blur node whenever quality matters. + +// ============================================================================ +// BlurHQRenderData +// ============================================================================ +struct BlurHQRenderData : public NodeRenderData { + float radius = 8.0f; +}; + +// ============================================================================ +// BlurHQRenderer +// ============================================================================ +class BlurHQRenderer : public NodeTextureRenderer { +public: + void render(NodeRenderContext& ctx, const NodeRenderData& baseData) override + { + auto& data = static_cast(baseData); + auto gl = ctx.gl; + auto cache = ctx.cache; + int w = ctx.textureWidth; + int h = ctx.textureHeight; + + // No input — output black + if (ctx.inputs.isEmpty() || ctx.inputs[0].textureId == 0) { + cache->bindFboToTexture(ctx.outputTextureId); + gl->glViewport(0, 0, w, h); + gl->glClearColor(0, 0, 0, 1); + gl->glClear(GL_COLOR_BUFFER_BIT); + return; + } + + GLuint hShader = cache->getOrCompileShader( + "blurhq_horizontal", + RenderResourceCache::standardVertexSource(), + horizontalFrag()); + GLuint vShader = cache->getOrCompileShader( + "blurhq_vertical", + RenderResourceCache::standardVertexSource(), + verticalFrag()); + + // Intermediate texture for the horizontal pass result + GLuint intermediate = cache->acquireTexture(w, h); + + // --- Pass 1: horizontal blur --- + cache->bindFboToTexture(intermediate); + ctx.useShader(hShader); + ctx.bindTexture(hShader, "u_image", ctx.inputs[0].textureId, 0); + gl->glUniform1f( + gl->glGetUniformLocation(hShader, "u_radius"), data.radius); + gl->glUniform2f( + gl->glGetUniformLocation(hShader, "_textureSize"), + float(w), float(h)); + ctx.drawQuad(); + + // --- Pass 2: vertical blur --- + cache->bindFboToTexture(ctx.outputTextureId); + ctx.useShader(vShader); + ctx.bindTexture(vShader, "u_image", intermediate, 0); + gl->glUniform1f( + gl->glGetUniformLocation(vShader, "u_radius"), data.radius); + gl->glUniform2f( + gl->glGetUniformLocation(vShader, "_textureSize"), + float(w), float(h)); + ctx.drawQuad(); + + cache->releaseTexture(intermediate); + } + +private: + // Shared Gaussian sampling code — axis is injected per-pass + static QString gaussianBody(const QString& axis) + { + return QString(R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_image; + uniform vec2 _textureSize; + uniform float u_radius; + + void main() + { + vec2 uv = v_texCoord; + vec2 step = 1.0 / _textureSize; + + float sigma = max(u_radius / 3.0, 0.001); + float twoSigSq = 2.0 * sigma * sigma; + vec4 result = vec4(0.0); + float totalW = 0.0; + + int radius = int(ceil(u_radius)); + for (int i = -radius; i <= radius; i++) { + float w = exp(-float(i * i) / twoSigSq); + vec2 offset = %1 * float(i); + result += texture(u_image, uv + offset * step) * w; + totalW += w; + } + + fragColor = result / max(totalW, 0.0001); + } + )"""").arg(axis); + } + + static QString horizontalFrag() { return gaussianBody("vec2(1.0, 0.0)"); } + static QString verticalFrag() { return gaussianBody("vec2(0.0, 1.0)"); } +}; + +// ============================================================================ +// BlurHQNode +// ============================================================================ +void BlurHQNode::init() +{ + this->title = "Blur HQ"; + + this->addInput("image"); + + this->addFloatProp("radius", "Radius", 8.0, 0.5, 128.0, 0.5); +} + +std::shared_ptr BlurHQNode::createRenderer() +{ + return std::make_shared(); +} + +std::shared_ptr BlurHQNode::createRenderData() +{ + auto data = std::make_shared(); + auto* prop = dynamic_cast(getProp("radius")); + if (prop) data->radius = static_cast(prop->value); + return data; +} From 7dd95f6530018328477e9a998daedb4579743db6 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 15 Apr 2026 03:27:49 -0500 Subject: [PATCH 017/164] add auto levels node --- src/texturelab/libraries/v3/autolevels.cpp | 267 +++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 src/texturelab/libraries/v3/autolevels.cpp diff --git a/src/texturelab/libraries/v3/autolevels.cpp b/src/texturelab/libraries/v3/autolevels.cpp new file mode 100644 index 00000000..2d93f71c --- /dev/null +++ b/src/texturelab/libraries/v3/autolevels.cpp @@ -0,0 +1,267 @@ +#include "../../graphics/noderenderer.h" +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +#include +#include + +// GPU min/max reduction via progressive 2×2 downsampling. +// Pipeline: +// 1. Init pass — convert full-res source to luma (per_channel=false) +// or keep RGB (per_channel=true) +// 2. Min chain — halve resolution each step, computing component-wise min +// 3. Max chain — same for max +// Both chains converge to a 1×1 texture storing the global min/max. +// 4. Apply pass — remap source using the 1×1 min/max values + gamma. +// +// This gives a true per-frame automatic levels without any CPU readback. +// Follow with a Curve or Map Range node for further tonal shaping. + +// ============================================================================ +// AutoLevelsRenderData +// ============================================================================ +struct AutoLevelsRenderData : public NodeRenderData { + float gamma = 1.0f; + bool perChannel = false; +}; + +// ============================================================================ +// AutoLevelsRenderer +// ============================================================================ +class AutoLevelsRenderer : public NodeTextureRenderer { +public: + void render(NodeRenderContext& ctx, const NodeRenderData& baseData) override + { + auto& data = static_cast(baseData); + auto gl = ctx.gl; + auto cache = ctx.cache; + int w = ctx.textureWidth; + int h = ctx.textureHeight; + + if (ctx.inputs.isEmpty() || ctx.inputs[0].textureId == 0) { + cache->bindFboToTexture(ctx.outputTextureId); + gl->glViewport(0, 0, w, h); + gl->glClearColor(0, 0, 0, 1); + gl->glClear(GL_COLOR_BUFFER_BIT); + return; + } + + GLuint srcTex = ctx.inputs[0].textureId; + GLuint initSh = cache->getOrCompileShader("al_init", stdVert(), initFrag()); + GLuint minSh = cache->getOrCompileShader("al_min", stdVert(), reduceFrag(true)); + GLuint maxSh = cache->getOrCompileShader("al_max", stdVert(), reduceFrag(false)); + GLuint applySh = cache->getOrCompileShader("al_apply", stdVert(), applyFrag()); + + // --- 1. Init pass (full resolution) --- + // per_channel=false → store luma in RGB so all 3 channels reduce identically + // per_channel=true → keep RGB as-is + GLuint initTex = cache->acquireTexture(w, h); + cache->bindFboToTexture(initTex); + ctx.useShader(initSh); + ctx.bindTexture(initSh, "u_src", srcTex, 0); + gl->glUniform1i( + gl->glGetUniformLocation(initSh, "u_per_channel"), + data.perChannel ? 1 : 0); + ctx.drawQuad(); + + // --- 2 & 3. Min/Max reduction chains --- + GLuint minTex = reduce(ctx, cache, gl, initTex, w, h, minSh); + GLuint maxTex = reduce(ctx, cache, gl, initTex, w, h, maxSh); + + cache->releaseTexture(initTex); + + // --- 4. Apply pass (full resolution) --- + cache->bindFboToTexture(ctx.outputTextureId); + ctx.useShader(applySh); + ctx.bindTexture(applySh, "u_src", srcTex, 0); + ctx.bindTexture(applySh, "u_min", minTex, 1); + ctx.bindTexture(applySh, "u_max", maxTex, 2); + gl->glUniform1f( + gl->glGetUniformLocation(applySh, "u_gamma"), + data.gamma); + gl->glUniform1i( + gl->glGetUniformLocation(applySh, "u_per_channel"), + data.perChannel ? 1 : 0); + ctx.drawQuad(); + + cache->releaseTexture(minTex); + cache->releaseTexture(maxTex); + } + +private: + // Progressively halve the texture, applying the given reduce shader + // (min or max) at each step. Returns a 1×1 texture the caller must release. + static GLuint reduce(NodeRenderContext& ctx, RenderResourceCache* cache, + QOpenGLFunctions_3_2_Core* gl, + GLuint srcTex, int w, int h, GLuint shader) + { + GLuint current = srcTex; + int cw = w, ch = h; + bool ownsCurrent = false; + + while (cw > 1 || ch > 1) { + int nextW = std::max(cw / 2, 1); + int nextH = std::max(ch / 2, 1); + GLuint next = cache->acquireTexture(nextW, nextH); + + // Bind FBO manually — we don't use ctx.useShader here because + // it would force the viewport to the node's final output size. + cache->bindFboToTexture(next); + gl->glViewport(0, 0, nextW, nextH); + gl->glUseProgram(shader); + + // Tell the shader how big the SOURCE texture is so it can + // compute the correct half-texel offset for 2×2 sampling. + gl->glUniform2f( + gl->glGetUniformLocation(shader, "u_src_size"), + float(cw), float(ch)); + + ctx.bindTexture(shader, "u_src", current, 0); + ctx.drawQuad(); + + if (ownsCurrent) cache->releaseTexture(current); + current = next; + ownsCurrent = true; + cw = nextW; + ch = nextH; + } + + return current; // 1×1 result, caller must release + } + + static QString stdVert() { return RenderResourceCache::standardVertexSource(); } + + // 1. Init: convert source to values suitable for reduction + static QString initFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_src; + uniform int u_per_channel; + + void main() + { + vec4 c = texture(u_src, v_texCoord); + if (u_per_channel == 1) { + // Keep RGB — reduce each channel independently + fragColor = vec4(c.rgb, 1.0); + } else { + // Replicate luma to RGB so all channels carry the same value; + // the min/max of any channel then equals the global luma min/max. + float luma = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); + fragColor = vec4(luma, luma, luma, 1.0); + } + } + )""""; + } + + // 2/3. Reduction: component-wise min or max over a 2×2 block of the source + static QString reduceFrag(bool isMin) + { + QString op = isMin ? "min" : "max"; + return QString(R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_src; + uniform vec2 u_src_size; // size of the INPUT texture for this pass + + void main() + { + // Half-texel offset in source-texture space centres the 4 samples + // on the 2×2 block that maps to this output pixel. + vec2 ht = 0.5 / u_src_size; + vec2 uv = v_texCoord; + + vec4 s00 = texture(u_src, uv + vec2(-ht.x, -ht.y)); + vec4 s10 = texture(u_src, uv + vec2( ht.x, -ht.y)); + vec4 s01 = texture(u_src, uv + vec2(-ht.x, ht.y)); + vec4 s11 = texture(u_src, uv + vec2( ht.x, ht.y)); + + fragColor = %1(%1(s00, s10), %1(s01, s11)); + } + )"""").arg(op); + } + + // 4. Apply: remap using the 1×1 min/max textures + gamma + static QString applyFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_src; + uniform sampler2D u_min; + uniform sampler2D u_max; + uniform float u_gamma; + uniform int u_per_channel; + + float remap(float v, float lo, float hi) { + float range = max(hi - lo, 0.001); + float t = clamp((v - lo) / range, 0.0, 1.0); + // Gamma > 1 brightens midtones; gamma < 1 darkens. + return (u_gamma > 0.999 && u_gamma < 1.001) + ? t + : pow(t, 1.0 / max(u_gamma, 0.001)); + } + + void main() + { + vec4 src = texture(u_src, v_texCoord); + // 1×1 textures — sample at the centre; exact UV doesn't matter. + vec4 lo = texture(u_min, vec2(0.5)); + vec4 hi = texture(u_max, vec2(0.5)); + + vec3 result; + if (u_per_channel == 1) { + // Independent per-channel remap (can shift colour balance) + result.r = remap(src.r, lo.r, hi.r); + result.g = remap(src.g, lo.g, hi.g); + result.b = remap(src.b, lo.b, hi.b); + } else { + // Luminance-preserving remap: scale RGB uniformly so hue is kept + float luma = dot(src.rgb, vec3(0.2126, 0.7152, 0.0722)); + float newLuma = remap(luma, lo.r, hi.r); + float scale = (luma > 0.0001) ? newLuma / luma : 0.0; + result = clamp(src.rgb * scale, 0.0, 1.0); + } + + fragColor = vec4(result, src.a); + } + )""""; + } +}; + +// ============================================================================ +// AutoLevelsNode +// ============================================================================ +void AutoLevelsNode::init() +{ + this->title = "Auto Levels"; + + this->addInput("image"); + + this->addFloatProp("gamma", "Midpoint Gamma", 1.0, 0.1, 4.0, 0.05); + this->addBoolProp ("per_channel", "Per Channel", false); +} + +std::shared_ptr AutoLevelsNode::createRenderer() +{ + return std::make_shared(); +} + +std::shared_ptr AutoLevelsNode::createRenderData() +{ + auto data = std::make_shared(); + if (auto* p = dynamic_cast(getProp("gamma"))) + data->gamma = static_cast(p->value); + if (auto* p = dynamic_cast(getProp("per_channel"))) + data->perChannel = p->value; + return data; +} From e0e0414267c45b14229d0f015a304ee9562dc790 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 15 Apr 2026 03:28:48 -0500 Subject: [PATCH 018/164] add distance node --- .../libraries/v3/distancetransform.cpp | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 src/texturelab/libraries/v3/distancetransform.cpp diff --git a/src/texturelab/libraries/v3/distancetransform.cpp b/src/texturelab/libraries/v3/distancetransform.cpp new file mode 100644 index 00000000..0a6f753f --- /dev/null +++ b/src/texturelab/libraries/v3/distancetransform.cpp @@ -0,0 +1,234 @@ +#include "../../graphics/noderenderer.h" +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +#include +#include +#include + +// Rong & Tan, "Jump Flooding in GPU with Applications to Voronoi Diagram and +// Distance Transform," I3D 2006 +// https://bgolus.medium.com/the-quest-for-very-wide-outlines-ba82ed442cd9 +// (Ben Golus — GPU distance transforms, JFA accuracy analysis) +// +// Jump Flooding Algorithm (JFA) Euclidean distance transform. +// Each foreground pixel in the thresholded mask seeds the JFA; subsequent +// passes propagate nearest-seed UVs across the texture in O(log N) passes. +// The final pass converts nearest-seed UV to a normalised distance value. +// +// Reuses the same JFA infrastructure as BevelV2; this node exposes the raw +// distance field rather than a bevel profile, making it a universal primitive +// for soft edge halos, wear gradients and stencil masks. + +// ============================================================================ +// DistanceTransformRenderData +// ============================================================================ +struct DistanceTransformRenderData : public NodeRenderData { + float threshold = 0.5f; + float spread = 0.5f; + bool invert = false; +}; + +// ============================================================================ +// DistanceTransformRenderer +// ============================================================================ +class DistanceTransformRenderer : public NodeTextureRenderer { +public: + void render(NodeRenderContext& ctx, const NodeRenderData& baseData) override + { + auto& data = static_cast(baseData); + auto gl = ctx.gl; + auto cache = ctx.cache; + int w = ctx.textureWidth; + int h = ctx.textureHeight; + + if (ctx.inputs.isEmpty() || ctx.inputs[0].textureId == 0) { + cache->bindFboToTexture(ctx.outputTextureId); + gl->glViewport(0, 0, w, h); + gl->glClearColor(0, 0, 0, 1); + gl->glClear(GL_COLOR_BUFFER_BIT); + return; + } + + GLuint seedShader = cache->getOrCompileShader( + "jfadt_seed", RenderResourceCache::standardVertexSource(), seedFrag()); + GLuint jfaShader = cache->getOrCompileShader( + "jfadt_step", RenderResourceCache::standardVertexSource(), jfaFrag()); + GLuint distShader = cache->getOrCompileShader( + "jfadt_dist", RenderResourceCache::standardVertexSource(), distFrag()); + + GLuint texA = cache->acquireTexture(w, h); + GLuint texB = cache->acquireTexture(w, h); + + // --- Seed pass: foreground pixels write their own UV; background writes (-1,-1) --- + cache->bindFboToTexture(texA); + ctx.useShader(seedShader); + ctx.bindTexture(seedShader, "u_mask", ctx.inputs[0].textureId, 0); + gl->glUniform1f( + gl->glGetUniformLocation(seedShader, "u_threshold"), + data.threshold); + ctx.drawQuad(); + + // --- JFA passes --- + int maxDim = std::max(w, h); + int stepSize = maxDim / 2; + while (stepSize >= 1) { + cache->bindFboToTexture(texB); + ctx.useShader(jfaShader); + ctx.bindTexture(jfaShader, "u_input", texA, 0); + gl->glUniform1i( + gl->glGetUniformLocation(jfaShader, "u_step"), stepSize); + ctx.drawQuad(); + std::swap(texA, texB); + stepSize /= 2; + } + + // --- Distance pass: convert nearest-seed UV to normalised distance --- + cache->bindFboToTexture(ctx.outputTextureId); + ctx.useShader(distShader); + ctx.bindTexture(distShader, "u_jfa", texA, 0); + gl->glUniform1f( + gl->glGetUniformLocation(distShader, "u_spread"), + data.spread); + gl->glUniform1i( + gl->glGetUniformLocation(distShader, "u_invert"), + data.invert ? 1 : 0); + ctx.drawQuad(); + + cache->releaseTexture(texA); + cache->releaseTexture(texB); + } + +private: + // Seed pass: store UV in RG if foreground, sentinel (-1,-1) if background + static QString seedFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_mask; + uniform float u_threshold; + + void main() + { + float v = texture(u_mask, v_texCoord).r; + if (v >= u_threshold) + fragColor = vec4(v_texCoord, 0.0, 1.0); // seed: own UV + else + fragColor = vec4(-1.0, -1.0, 0.0, 1.0); // no seed + } + )""""; + } + + // JFA step: for each pixel, sample 8 neighbours at ±stepSize and keep + // whichever carries the nearest valid seed UV + static QString jfaFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_input; + uniform int u_step; + + void main() + { + vec2 texSize = vec2(textureSize(u_input, 0)); + vec2 step = vec2(float(u_step)) / texSize; + + vec2 bestUV = vec2(-1.0); + float bestDist = 1e9; + + for (int x = -1; x <= 1; x++) { + for (int y = -1; y <= 1; y++) { + vec2 sampleUV = v_texCoord + vec2(float(x), float(y)) * step; + vec4 s = texture(u_input, sampleUV); + vec2 seedUV = s.rg; + if (seedUV.x < 0.0) continue; // no seed stored here + float d = length(v_texCoord - seedUV); + if (d < bestDist) { + bestDist = d; + bestUV = seedUV; + } + } + } + + fragColor = vec4(bestUV, 0.0, 1.0); + } + )""""; + } + + // Distance pass: convert nearest-seed UV to a normalised, spread-scaled + // grayscale value + static QString distFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_jfa; + uniform float u_spread; + uniform int u_invert; + + void main() + { + vec2 seedUV = texture(u_jfa, v_texCoord).rg; + + float dist; + if (seedUV.x < 0.0) { + // No seed found — maximum distance + dist = 1.0; + } else { + // Raw distance in [0,~1.41] UV space + dist = length(v_texCoord - seedUV); + // Scale by spread: spread=0.5 → moderate falloff + float invSpread = 1.0 / max(u_spread, 0.001); + dist = clamp(dist * invSpread * 2.0, 0.0, 1.0); + } + + if (u_invert == 0) + dist = 1.0 - dist; // bright near seed, dark far away (default) + + fragColor = vec4(vec3(dist), 1.0); + } + )""""; + } +}; + +// ============================================================================ +// DistanceTransformNode +// ============================================================================ +void DistanceTransformNode::init() +{ + this->title = "Distance Transform"; + + this->addInput("mask"); + + this->addFloatProp("threshold", "Threshold", 0.5, 0.0, 1.0, 0.01); + this->addFloatProp("spread", "Spread", 0.5, 0.0, 1.0, 0.01); + this->addBoolProp ("invert", "Invert", false); +} + +std::shared_ptr DistanceTransformNode::createRenderer() +{ + return std::make_shared(); +} + +std::shared_ptr DistanceTransformNode::createRenderData() +{ + auto data = std::make_shared(); + + if (auto* p = dynamic_cast(getProp("threshold"))) + data->threshold = static_cast(p->value); + if (auto* p = dynamic_cast(getProp("spread"))) + data->spread = static_cast(p->value); + if (auto* p = dynamic_cast(getProp("invert"))) + data->invert = p->value; + + return data; +} From bfb519daa4371bb2fcc41b17afda8a66a7f06d99 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 17 May 2026 22:39:57 -0500 Subject: [PATCH 019/164] add edge detection node --- src/texturelab/libraries/v3/edgedetect.cpp | 82 ++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/texturelab/libraries/v3/edgedetect.cpp diff --git a/src/texturelab/libraries/v3/edgedetect.cpp b/src/texturelab/libraries/v3/edgedetect.cpp new file mode 100644 index 00000000..0987ed68 --- /dev/null +++ b/src/texturelab/libraries/v3/edgedetect.cpp @@ -0,0 +1,82 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// https://en.wikipedia.org/wiki/Sobel_operator +// https://docs.opencv.org/4.x/d2/d2c/tutorial_sobel_derivatives.html +void EdgeDetectNode::init() +{ + this->title = "Edge Detect"; + + this->addInput("image"); + + auto kernelProp = this->addEnumProp("kernel", "Kernel", + {"Sobel", "Prewitt", "Laplacian"}); + kernelProp->index = 0; + + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 4.0, 0.1); + this->addFloatProp("threshold", "Threshold", 0.0, 0.0, 1.0, 0.01); + this->addFloatProp("width", "Width", 1.0, 0.5, 8.0, 0.5); + this->addBoolProp ("invert", "Invert", false); + + auto source = R""""( + #define KERNEL_SOBEL 0 + #define KERNEL_PREWITT 1 + #define KERNEL_LAPLACIAN 2 + + float luminance(vec3 c) { + return dot(c, vec3(0.2126, 0.7152, 0.0722)); + } + + float sampleLum(vec2 uv) { + return luminance(texture(image, uv).rgb); + } + + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.0, 0.0, 0.0, 1.0); + + vec2 step = (prop_width / _textureSize.xy); + + float tl = sampleLum(uv + vec2(-step.x, step.y)); + float t = sampleLum(uv + vec2( 0.0, step.y)); + float tr = sampleLum(uv + vec2( step.x, step.y)); + float l = sampleLum(uv + vec2(-step.x, 0.0)); + float r = sampleLum(uv + vec2( step.x, 0.0)); + float bl = sampleLum(uv + vec2(-step.x, -step.y)); + float b = sampleLum(uv + vec2( 0.0, -step.y)); + float br = sampleLum(uv + vec2( step.x, -step.y)); + + float gx = 0.0; + float gy = 0.0; + float edge = 0.0; + + if (prop_kernel == KERNEL_SOBEL) { + gx = -tl - 2.0*l - bl + tr + 2.0*r + br; + gy = -tl - 2.0*t - tr + bl + 2.0*b + br; + edge = length(vec2(gx, gy)); + } else if (prop_kernel == KERNEL_PREWITT) { + gx = -tl - l - bl + tr + r + br; + gy = -tl - t - tr + bl + b + br; + edge = length(vec2(gx, gy)); + } else { + // Laplacian: [-1,-1,-1; -1,8,-1; -1,-1,-1] × center + float center = sampleLum(uv); + edge = abs(8.0 * center - (tl + t + tr + l + r + bl + b + br)); + } + + edge *= prop_intensity; + edge = max(0.0, edge - prop_threshold); + + if (prop_invert) + edge = 1.0 - clamp(edge, 0.0, 1.0); + else + edge = clamp(edge, 0.0, 1.0); + + return vec4(vec3(edge), 1.0); + } + )""""; + + this->setShaderSource(source); +} From c29ecca078eeb9afd4f5f80f36b07144bd2d19b9 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 17 May 2026 22:42:58 -0500 Subject: [PATCH 020/164] add color to mask node --- src/texturelab/libraries/v3/colortomask.cpp | 70 +++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/texturelab/libraries/v3/colortomask.cpp diff --git a/src/texturelab/libraries/v3/colortomask.cpp b/src/texturelab/libraries/v3/colortomask.cpp new file mode 100644 index 00000000..cc468658 --- /dev/null +++ b/src/texturelab/libraries/v3/colortomask.cpp @@ -0,0 +1,70 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// https://en.wikipedia.org/wiki/HSL_and_HSV +// Converts a target colour range in an input image to a greyscale mask. +// Uses HSL-space distance for perceptually accurate colour matching — far +// more reliable than RGB Euclidean distance. +// Primary use: extract zones from a flat-colour ID map to drive per-material +// Height Blend, roughness or metalness branches. +void ColorToMaskNode::init() +{ + this->title = "Color To Mask"; + + this->addInput("image"); + + this->addColorProp("target_color", "Target Color", QColor(255, 0, 0)); + this->addFloatProp("hue_range", "Hue Range", 1.0, 0.1, 4.0, 0.1); + this->addFloatProp("sat_range", "Sat Range", 0.5, 0.0, 4.0, 0.1); + this->addFloatProp("lum_range", "Lum Range", 0.3, 0.0, 4.0, 0.1); + this->addFloatProp("softness", "Softness", 0.2, 0.01, 1.0, 0.01); + this->addBoolProp ("invert", "Invert", false); + + auto source = R""""( + vec3 rgb2hsl(vec3 c) { + float maxC = max(c.r, max(c.g, c.b)); + float minC = min(c.r, min(c.g, c.b)); + float delta = maxC - minC; + + float h = 0.0; + if (delta > 0.001) { + if (maxC == c.r) h = mod((c.g - c.b) / delta, 6.0); + else if (maxC == c.g) h = (c.b - c.r) / delta + 2.0; + else h = (c.r - c.g) / delta + 4.0; + h /= 6.0; + if (h < 0.0) h += 1.0; + } + float l = (maxC + minC) * 0.5; + float s = (delta < 0.001) ? 0.0 + : delta / (1.0 - abs(2.0 * l - 1.0)); + return vec3(h, s, l); + } + + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.0, 0.0, 0.0, 1.0); + + vec3 col = texture(image, uv).rgb; + vec3 hsl = rgb2hsl(col); + vec3 targetHSL = rgb2hsl(prop_target_color.rgb); + + // Hue distance is circular + float hueDist = abs(hsl.x - targetHSL.x); + hueDist = min(hueDist, 1.0 - hueDist); + + float dist = hueDist * prop_hue_range + + abs(hsl.y - targetHSL.y) * prop_sat_range + + abs(hsl.z - targetHSL.z) * prop_lum_range; + + float mask = 1.0 - smoothstep(0.0, prop_softness, dist); + + if (prop_invert) mask = 1.0 - mask; + + return vec4(vec3(clamp(mask, 0.0, 1.0)), 1.0); + } + )""""; + + this->setShaderSource(source); +} From 3221d44b78f037ed108dc07882791480426545ce Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 17 May 2026 22:58:22 -0500 Subject: [PATCH 021/164] add rough grain --- src/texturelab/libraries/v3/roughgrain.cpp | 92 ++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/texturelab/libraries/v3/roughgrain.cpp diff --git a/src/texturelab/libraries/v3/roughgrain.cpp b/src/texturelab/libraries/v3/roughgrain.cpp new file mode 100644 index 00000000..64f7a0a6 --- /dev/null +++ b/src/texturelab/libraries/v3/roughgrain.cpp @@ -0,0 +1,92 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Rough Grain — dense field of fine granular dots/specks. +// Produced by layered anisotropic noise with random per-layer orientation and +// per-column phase shifts; the high-frequency phase aliasing breaks the +// would-be streaks into isotropic grains. +// Uses the engine-wide _seed uniform so a single Random Seed change in the +// project regenerates this grain consistently with other procedural nodes. +// Use as: micro-roughness variation on painted surfaces, sandblasted metal +// base, fine textile noise, or the granular substrate of any material. +void RoughGrainNode::init() +{ + this->title = "Rough Grain"; + + this->addInput("mask"); + + this->addIntProp ("density", "Density", 400, 10, 2000, 10); + this->addFloatProp("grain_size", "Grain Size", 0.04, 0.001, 0.3, 0.001); + this->addFloatProp("variation", "Variation", 0.5, 0.0, 1.0, 0.01); + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 2.0, 0.05); + this->addIntProp ("layers", "Layers", 2, 1, 4, 1); + + auto source = R""""( + #define PI 3.14159265358979 + + // 2-D hash with engine-wide seed offset + float grainHash(vec2 p) { + p += vec2(_seed * 0.127, _seed * 0.311); + return hash12(p); + } + + // One layer of anisotropic noise at a given orientation and frequency. + // When the perpendicular axis is stretched far more than the primary + // axis, the per-column random phase aliases the would-be streaks into + // tightly packed dots — that grain effect is the whole point. + float grainLayer(vec2 uv, float freq, float angleRad) { + float cosA = cos(angleRad); + float sinA = sin(angleRad); + + vec2 rot = vec2(uv.x * cosA + uv.y * sinA, + -uv.x * sinA + uv.y * cosA); + + // Heavy anisotropic scaling (drives the dot aliasing) + rot.x *= freq * 0.05; + rot.y *= freq * 10.0; + + // Per-column phase perturbation + float perturb = (grainHash(vec2(rot.x, 0.0)) * 2.0 - 1.0) + * prop_variation * 0.5; + rot.y += perturb * freq; + + float phase = grainHash(vec2(floor(rot.y * float(prop_density)), 0.731)); + + float pos = fract(rot.y * float(prop_density) + phase); + float halfW = prop_grain_size * 0.5; + float grain = smoothstep(0.5 - halfW, 0.5, pos) + * (1.0 - smoothstep(0.5, 0.5 + halfW, pos)); + + return grain; + } + + vec4 process(vec2 uv) + { + float result = 0.0; + float weight = 1.0; + float totalW = 0.0; + + for (int i = 0; i < prop_layers; i++) { + // Random orientation per layer — multi-directional dot field + float layerAngle = grainHash(vec2(float(i) * 3.7, 1.1)) * PI * 2.0; + float freq = 1.0 + float(i) * 0.5; + + result += grainLayer(uv, freq, layerAngle) * weight; + totalW += weight; + weight *= 0.55; + } + + result = (totalW > 0.0) ? result / totalW : 0.0; + result = clamp(result * prop_intensity, 0.0, 1.0); + + if (mask_connected) { + result *= texture(mask, uv).r; + } + + return vec4(vec3(result), 1.0); + } + )""""; + + this->setShaderSource(source); +} From 0651f9dada01f4fc74ff423b8f4e1469d2ee74a7 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 17 May 2026 23:10:59 -0500 Subject: [PATCH 022/164] add directional scratches --- .../libraries/v3/directionalscratches.cpp | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/texturelab/libraries/v3/directionalscratches.cpp diff --git a/src/texturelab/libraries/v3/directionalscratches.cpp b/src/texturelab/libraries/v3/directionalscratches.cpp new file mode 100644 index 00000000..3db500b3 --- /dev/null +++ b/src/texturelab/libraries/v3/directionalscratches.cpp @@ -0,0 +1,122 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Procedural directional scratch / brushed-metal generator. +// Divides the rotated UV space into N horizontal bands; each band has a +// chance to contain ONE scratch with random start position, length, vertical +// offset within the band, thickness and brightness. +// This discrete-segment approach produces actual visible scratch lines — +// not the aliased dot pattern of frequency-based line noise. +// +// Layer two instances at 0° and ~25° with Screen / Max blend for the classic +// cross-hatched aged metal look used throughout R&C: Rift Apart's weaponry. +void DirectionalScratchesNode::init() +{ + this->title = "Directional Scratches"; + + this->addInput("mask"); + + this->addFloatProp("angle", "Angle", 0.0, -180.0, 180.0, 1.0); + this->addFloatProp("angle_var", "Angle Variance", 5.0, 0.0, 45.0, 0.5); + this->addIntProp ("count", "Count", 80, 10, 500, 5); + this->addFloatProp("density", "Density", 0.5, 0.0, 1.0, 0.01); + this->addFloatProp("length", "Length", 0.6, 0.05, 1.0, 0.01); + this->addFloatProp("length_var", "Length Variance", 0.5, 0.0, 1.0, 0.05); + this->addFloatProp("width", "Width", 0.3, 0.05, 1.0, 0.01); + this->addFloatProp("waviness", "Waviness", 0.0, 0.0, 1.0, 0.01); + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 2.0, 0.05); + this->addIntProp ("layers", "Layers", 1, 1, 4, 1); + + auto source = R""""( + #define PI 3.14159265358979 + + // Hash with project-wide seed offset + float scrHash(vec2 p) { + return hash12(p + vec2(_seed * 0.131, _seed * 0.379)); + } + + // One layer of discrete scratch line segments. + // Each band gets at most one scratch with random properties. + float scratchLayer(vec2 uv, float angleRad, float layerIdx) { + float cosA = cos(angleRad); + float sinA = sin(angleRad); + + // Rotate UV into scratch-aligned space; scratches run along +X. + vec2 rot = vec2(uv.x * cosA + uv.y * sinA, + -uv.x * sinA + uv.y * cosA); + + float bandCount = float(prop_count); + float band = floor(rot.y * bandCount); + float bandFract = fract(rot.y * bandCount); + + // Per-band deterministic random properties + vec2 seed = vec2(band, layerIdx * 113.7); + float rExist = scrHash(seed + vec2(0.11, 0.23)); + float rStartX = scrHash(seed + vec2(0.37, 0.59)); + float rLength = scrHash(seed + vec2(0.71, 0.97)); + float rOffset = scrHash(seed + vec2(1.13, 1.31)); + float rThickness = scrHash(seed + vec2(1.51, 1.79)); + float rIntensity = scrHash(seed + vec2(1.97, 2.13)); + + // Sparsity: only a fraction of bands actually contain a scratch + if (rExist > prop_density) return 0.0; + + // Scratch length, with optional variance pulling shorter + float lenFactor = mix(1.0, rLength, prop_length_var); + float scratchLen = clamp(prop_length * lenFactor, 0.02, 1.0); + + // X-position along the scratch (wrapped so the pattern tiles in + // the scratch direction even after rotation) + float xLocal = fract(rot.x - rStartX); + if (xLocal > scratchLen) return 0.0; + + // Soft fade at both ends, proportional to length + float fade = min(0.04, scratchLen * 0.25); + float xMask = smoothstep(0.0, fade, xLocal) + * (1.0 - smoothstep(scratchLen - fade, scratchLen, xLocal)); + + // Optional gentle waviness along the scratch (kept within band) + float wave = sin(xLocal * 28.0 + band * 13.7) + * prop_waviness * 0.15; + + // Vertical position within the band (line centre, ±20% jitter) + float lineCenter = 0.5 + (rOffset - 0.5) * 0.4 + wave; + + // Line profile across band; width prop is fraction of band height + float dist = abs(bandFract - lineCenter); + float halfW = prop_width * 0.25 * mix(0.6, 1.0, rThickness); + float line = 1.0 - smoothstep(halfW * 0.5, halfW, dist); + + // Per-scratch brightness variation + return line * xMask * mix(0.4, 1.0, rIntensity); + } + + vec4 process(vec2 uv) + { + float baseAngle = prop_angle * PI / 180.0; + float varRad = prop_angle_var * PI / 180.0; + + float result = 0.0; + + for (int i = 0; i < prop_layers; i++) { + float fi = float(i); + float angleOffset = (scrHash(vec2(fi * 7.31, 0.5)) * 2.0 - 1.0) * varRad; + float layerAngle = baseAngle + angleOffset; + + // Take the max (Screen-like) so layers add without dimming + result = max(result, scratchLayer(uv, layerAngle, fi + 1.0)); + } + + result = clamp(result * prop_intensity, 0.0, 1.0); + + if (mask_connected) { + result *= texture(mask, uv).r; + } + + return vec4(vec3(result), 1.0); + } + )""""; + + this->setShaderSource(source); +} From bbc221c6fcf968d3073628a7e7a137437dfbd6b5 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 17 May 2026 23:50:15 -0500 Subject: [PATCH 023/164] make scratches seamless --- .../libraries/v3/directionalscratches.cpp | 86 ++++++++++--------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/src/texturelab/libraries/v3/directionalscratches.cpp b/src/texturelab/libraries/v3/directionalscratches.cpp index 3db500b3..bad2e8a0 100644 --- a/src/texturelab/libraries/v3/directionalscratches.cpp +++ b/src/texturelab/libraries/v3/directionalscratches.cpp @@ -5,20 +5,21 @@ // Procedural directional scratch / brushed-metal generator. // Divides the rotated UV space into N horizontal bands; each band has a // chance to contain ONE scratch with random start position, length, vertical -// offset within the band, thickness and brightness. -// This discrete-segment approach produces actual visible scratch lines — -// not the aliased dot pattern of frequency-based line noise. +// offset, thickness and brightness. // -// Layer two instances at 0° and ~25° with Screen / Max blend for the classic -// cross-hatched aged metal look used throughout R&C: Rift Apart's weaponry. +// Seamless tiling: angle is restricted to a stepped enum of geometrically +// tileable orientations (0°, 45°, 90°, 135°). Each option uses an +// integer-coefficient rotation matrix so that a UV-tile shift translates to +// an integer band-index shift; combined with mod()-wrapped band indices and +// fract()-wrapped scratch coordinates, the pattern tiles perfectly. void DirectionalScratchesNode::init() { this->title = "Directional Scratches"; - this->addInput("mask"); + auto angleProp = this->addEnumProp("angle", "Angle", + {"0° (Horizontal)", "45°", "90° (Vertical)", "135°"}); + angleProp->index = 0; - this->addFloatProp("angle", "Angle", 0.0, -180.0, 180.0, 1.0); - this->addFloatProp("angle_var", "Angle Variance", 5.0, 0.0, 45.0, 0.5); this->addIntProp ("count", "Count", 80, 10, 500, 5); this->addFloatProp("density", "Density", 0.5, 0.0, 1.0, 0.01); this->addFloatProp("length", "Length", 0.6, 0.05, 1.0, 0.01); @@ -31,23 +32,40 @@ void DirectionalScratchesNode::init() auto source = R""""( #define PI 3.14159265358979 + #define ANGLE_0 0 + #define ANGLE_45 1 + #define ANGLE_90 2 + #define ANGLE_135 3 + // Hash with project-wide seed offset float scrHash(vec2 p) { return hash12(p + vec2(_seed * 0.131, _seed * 0.379)); } + // Integer-coefficient rotation: preserves the property that a UV + // shift of (1,0) or (0,1) produces an integer rotated-coordinate + // shift, which is what makes the pattern tile perfectly. + // Diagonal angles (45°/135°) come out scaled by √2 — this just means + // diagonal patterns appear √2 denser per UV tile than cardinal ones. + vec2 rotateUV(vec2 uv) { + if (prop_angle == ANGLE_0) return uv; + if (prop_angle == ANGLE_45) return vec2(uv.x + uv.y, uv.y - uv.x); + if (prop_angle == ANGLE_90) return vec2(uv.y, -uv.x); + /* ANGLE_135 */ return vec2(uv.y - uv.x, -uv.x - uv.y); + } + // One layer of discrete scratch line segments. // Each band gets at most one scratch with random properties. - float scratchLayer(vec2 uv, float angleRad, float layerIdx) { - float cosA = cos(angleRad); - float sinA = sin(angleRad); - - // Rotate UV into scratch-aligned space; scratches run along +X. - vec2 rot = vec2(uv.x * cosA + uv.y * sinA, - -uv.x * sinA + uv.y * cosA); + float scratchLayer(vec2 uv, float layerIdx) { + vec2 rot = rotateUV(uv); float bandCount = float(prop_count); - float band = floor(rot.y * bandCount); + + // mod() wraps band index to [0, bandCount-1] so band hashes match + // across UV-tile boundaries. Integer-rotation guarantees that + // floor(rot.y * bandCount) shifts by exactly bandCount across one + // UV tile, so the mod is exact. + float band = mod(floor(rot.y * bandCount), bandCount); float bandFract = fract(rot.y * bandCount); // Per-band deterministic random properties @@ -63,27 +81,26 @@ void DirectionalScratchesNode::init() if (rExist > prop_density) return 0.0; // Scratch length, with optional variance pulling shorter - float lenFactor = mix(1.0, rLength, prop_length_var); + float lenFactor = mix(1.0, rLength, prop_length_var); float scratchLen = clamp(prop_length * lenFactor, 0.02, 1.0); - // X-position along the scratch (wrapped so the pattern tiles in - // the scratch direction even after rotation) + // X-position along the scratch — fract handles tiling automatically float xLocal = fract(rot.x - rStartX); if (xLocal > scratchLen) return 0.0; // Soft fade at both ends, proportional to length - float fade = min(0.04, scratchLen * 0.25); + float fade = min(0.04, scratchLen * 0.25); float xMask = smoothstep(0.0, fade, xLocal) * (1.0 - smoothstep(scratchLen - fade, scratchLen, xLocal)); - // Optional gentle waviness along the scratch (kept within band) + // Optional gentle waviness along the scratch float wave = sin(xLocal * 28.0 + band * 13.7) * prop_waviness * 0.15; - // Vertical position within the band (line centre, ±20% jitter) - float lineCenter = 0.5 + (rOffset - 0.5) * 0.4 + wave; + // Vertical position within the band + float lineCenter = 0.5 + (rOffset - 0.5) * 0.3 + wave; - // Line profile across band; width prop is fraction of band height + // Line profile across band; width is fraction of band height float dist = abs(bandFract - lineCenter); float halfW = prop_width * 0.25 * mix(0.6, 1.0, rThickness); float line = 1.0 - smoothstep(halfW * 0.5, halfW, dist); @@ -94,27 +111,12 @@ void DirectionalScratchesNode::init() vec4 process(vec2 uv) { - float baseAngle = prop_angle * PI / 180.0; - float varRad = prop_angle_var * PI / 180.0; - float result = 0.0; - for (int i = 0; i < prop_layers; i++) { - float fi = float(i); - float angleOffset = (scrHash(vec2(fi * 7.31, 0.5)) * 2.0 - 1.0) * varRad; - float layerAngle = baseAngle + angleOffset; - - // Take the max (Screen-like) so layers add without dimming - result = max(result, scratchLayer(uv, layerAngle, fi + 1.0)); - } - - result = clamp(result * prop_intensity, 0.0, 1.0); - - if (mask_connected) { - result *= texture(mask, uv).r; + // Each layer generates an independent scratch set via the hash + result = max(result, scratchLayer(uv, float(i) + 1.0)); } - - return vec4(vec3(result), 1.0); + return vec4(vec3(clamp(result * prop_intensity, 0.0, 1.0)), 1.0); } )""""; From 5c3b51b8e8eccfc707a8bae64bae0cb37de8f760 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 18 May 2026 00:00:00 -0500 Subject: [PATCH 024/164] add vibrance --- src/texturelab/libraries/v3/vibrance.cpp | 50 ++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/texturelab/libraries/v3/vibrance.cpp diff --git a/src/texturelab/libraries/v3/vibrance.cpp b/src/texturelab/libraries/v3/vibrance.cpp new file mode 100644 index 00000000..f8737a04 --- /dev/null +++ b/src/texturelab/libraries/v3/vibrance.cpp @@ -0,0 +1,50 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// https://en.wikipedia.org/wiki/Colorfulness +// Vibrance selectively boosts the saturation of low-saturation pixels while +// protecting already-saturated hues from over-saturation clipping. +// Prefer Vibrance over raw Saturation for artist-safe color enhancement. +void VibranceNode::init() +{ + this->title = "Vibrance"; + + this->addInput("image"); + + this->addFloatProp("vibrance", "Vibrance", 0.3, -1.0, 1.0, 0.05); + this->addFloatProp("saturation", "Saturation", 0.0, -1.0, 1.0, 0.05); + + auto source = R""""( + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.0, 0.0, 0.0, 1.0); + + vec4 col = texture(image, uv); + float lum = dot(col.rgb, vec3(0.2126, 0.7152, 0.0722)); + vec3 gray = vec3(lum); + + // --- Uniform saturation (applied first) --- + vec3 saturated = mix(gray, col.rgb, 1.0 + prop_saturation); + + // --- Vibrance: pixel's current saturation range [0..1] --- + float maxC = max(saturated.r, max(saturated.g, saturated.b)); + float minC = min(saturated.r, min(saturated.g, saturated.b)); + float sat = maxC - minC; // 0 = fully gray, 1 = fully saturated + + // Protection factor: low-sat pixels get most boost, saturated pixels get none + float protection = 1.0 - sat; + + // Apply vibrance modulated by protection factor + float boostLum = dot(saturated, vec3(0.2126, 0.7152, 0.0722)); + vec3 boostGray = vec3(boostLum); + vec3 result = mix(boostGray, saturated, + 1.0 + prop_vibrance * protection); + + return vec4(clamp(result, 0.0, 1.0), col.a); + } + )""""; + + this->setShaderSource(source); +} From 8aa67fab915e214a2975293698107107be222f54 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 18 May 2026 00:17:48 -0500 Subject: [PATCH 025/164] add high pass --- src/texturelab/libraries/v3/highpass.cpp | 50 ++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/texturelab/libraries/v3/highpass.cpp diff --git a/src/texturelab/libraries/v3/highpass.cpp b/src/texturelab/libraries/v3/highpass.cpp new file mode 100644 index 00000000..221a5349 --- /dev/null +++ b/src/texturelab/libraries/v3/highpass.cpp @@ -0,0 +1,50 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// https://en.wikipedia.org/wiki/Unsharp_masking +// Highpass = input - blur(input) + 0.5 +// Output is neutral-gray where there is no detail; overlay-blend it onto a +// base material to layer micro-variation without altering base tone. +void HighpassNode::init() +{ + this->title = "Highpass"; + + this->addInput("image"); + + this->addFloatProp("radius", "Radius", 8.0, 0.5, 64.0, 0.5); + this->addIntProp ("quality", "Quality", 4, 1, 8, 1); + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 4.0, 0.1); + + auto source = R""""( + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.5, 0.5, 0.5, 1.0); + + vec4 original = texture(image, uv); + + // Box blur approximation of Gaussian + vec4 blurred = vec4(0.0); + float total = 0.0; + int q = prop_quality; + + for (int x = -q; x <= q; x++) { + for (int y = -q; y <= q; y++) { + vec2 offset = vec2(float(x), float(y)) + * prop_radius / _textureSize.xy; + blurred += texture(image, uv + offset); + total += 1.0; + } + } + blurred /= total; + + // Highpass centered on 0.5 so it is neutral for Overlay blending + vec4 detail = (original - blurred) * prop_intensity + 0.5; + + return clamp(detail, 0.0, 1.0); + } + )""""; + + this->setShaderSource(source); +} From 0f421669b1f784d56dca77ba038f6321173e6e30 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 18 May 2026 00:19:36 -0500 Subject: [PATCH 026/164] add emboss --- src/texturelab/libraries/v3/emboss.cpp | 66 ++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/texturelab/libraries/v3/emboss.cpp diff --git a/src/texturelab/libraries/v3/emboss.cpp b/src/texturelab/libraries/v3/emboss.cpp new file mode 100644 index 00000000..3386ca9c --- /dev/null +++ b/src/texturelab/libraries/v3/emboss.cpp @@ -0,0 +1,66 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// https://en.wikipedia.org/wiki/Emboss_(photography) +// Fake 2D directional light across a height-map surface. +// Distinct from Normal Map: outputs a shaded image, not a vector map. +// Use as a quick height-preview or for stylised cel-shading effects. +void EmbossNode::init() +{ + this->title = "Emboss"; + + this->addInput("image"); + + this->addFloatProp("angle", "Light Angle", 45.0, 0.0, 360.0, 1.0); + this->addFloatProp("elevation", "Light Elevation", 0.5, 0.0, 1.0, 0.05); + this->addFloatProp("intensity", "Intensity", 3.0, 0.1, 16.0, 0.1); + this->addBoolProp ("invert", "Invert Height", false); + + auto source = R""""( + #define PI 3.14159265358979 + + float sampleH(vec2 uv) { + float h = texture(image, uv).r; + return prop_invert ? 1.0 - h : h; + } + + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.5, 0.5, 0.5, 1.0); + + float angleRad = prop_angle * PI / 180.0; + vec2 texel = 1.0 / _textureSize.xy; + + // Central-difference gradient + float hL = sampleH(uv - vec2(texel.x, 0.0)); + float hR = sampleH(uv + vec2(texel.x, 0.0)); + float hD = sampleH(uv - vec2(0.0, texel.y)); + float hU = sampleH(uv + vec2(0.0, texel.y)); + + // Build surface normal from finite differences + // intensity scales how steeply height maps to angle + vec3 normal = normalize(vec3( + (hL - hR) * prop_intensity, + (hD - hU) * prop_intensity, + 1.0 + )); + + // Light direction from angle and elevation + float elev = prop_elevation * PI * 0.5; // [0..PI/2] + vec3 lightDir = normalize(vec3( + cos(angleRad) * cos(elev), + sin(angleRad) * cos(elev), + sin(elev) + )); + + float diffuse = dot(normal, lightDir); + float result = diffuse * 0.5 + 0.5; // remap [-1,1] → [0,1] + + return vec4(vec3(clamp(result, 0.0, 1.0)), 1.0); + } + )""""; + + this->setShaderSource(source); +} From 3f2da978ae68549429ce323e761bbb568c3c72f0 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 18 May 2026 00:30:04 -0500 Subject: [PATCH 027/164] add voronoise fractal --- .../libraries/v3/voronoifractal.cpp | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/texturelab/libraries/v3/voronoifractal.cpp diff --git a/src/texturelab/libraries/v3/voronoifractal.cpp b/src/texturelab/libraries/v3/voronoifractal.cpp new file mode 100644 index 00000000..e1fae002 --- /dev/null +++ b/src/texturelab/libraries/v3/voronoifractal.cpp @@ -0,0 +1,102 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Worley, "A Cellular Texture Basis Function," SIGGRAPH 1996 +// https://iquilezles.org/articles/voronoilines/ +// Multi-octave Worley noise (fractal Voronoi). Each octave doubles the +// frequency and halves the amplitude (Lacunarity / Gain). +// Output modes: F1 = distance to nearest cell (soft blobs), +// F2 = second nearest, F2-F1 = cell edges only (crack lines), +// F1+F2 = softer combined cells. +void VoronoiFractalNode::init() +{ + this->title = "Voronoi Fractal"; + + this->addFloatProp("scale", "Scale", 4.0, 0.5, 16.0, 0.5); + this->addIntProp ("octaves", "Octaves", 4, 1, 8, 1); + this->addFloatProp("lacunarity", "Lacunarity", 2.0, 1.5, 4.0, 0.1); + this->addFloatProp("gain", "Gain", 0.5, 0.2, 0.8, 0.05); + + auto distProp = this->addEnumProp("distance", "Distance Func", + {"Euclidean", "Manhattan", "Chebyshev"}); + distProp->index = 0; + + auto outProp = this->addEnumProp("output_mode", "Output", + {"F1", "F2", "F2-F1", "F1+F2"}); + outProp->index = 0; + + this->addIntProp("seed", "Seed", 0, 0, 999, 1); + + auto source = R""""( + #define DIST_EUCLIDEAN 0 + #define DIST_MANHATTAN 1 + #define DIST_CHEBYSHEV 2 + #define OUT_F1 0 + #define OUT_F2 1 + #define OUT_F2F1 2 + #define OUT_F1F2 3 + + vec2 cellPoint(vec2 cell) { + return cell + hash22(cell + vec2(float(prop_seed) * 0.193, float(prop_seed) * 0.457)); + } + + float cellDist(vec2 a, vec2 b) { + vec2 d = abs(a - b); + if (prop_distance == DIST_MANHATTAN) return d.x + d.y; + if (prop_distance == DIST_CHEBYSHEV) return max(d.x, d.y); + return length(a - b); // Euclidean + } + + // Returns (F1, F2) for a given scaled UV + vec2 voronoi(vec2 uv) { + vec2 cell = floor(uv); + vec2 local = fract(uv); + float f1 = 1e9, f2 = 1e9; + + for (int x = -2; x <= 2; x++) { + for (int y = -2; y <= 2; y++) { + vec2 nb = vec2(float(x), float(y)); + vec2 pt = cellPoint(cell + nb); + float d = cellDist(uv, pt); + if (d < f1) { f2 = f1; f1 = d; } + else if (d < f2) { f2 = d; } + } + } + // Normalise: in Euclidean space max F1 for uniform random points ≈ 0.67 + return vec2(f1, f2) / 0.67; + } + + vec4 process(vec2 uv) + { + float value = 0.0; + float amplitude = 1.0; + float freq = prop_scale; + float totalAmp = 0.0; + + for (int i = 0; i < prop_octaves; i++) { + vec2 f = voronoi(uv * freq); + + float octaveVal = 0.0; + if (prop_output_mode == OUT_F1) + octaveVal = f.x; + else if (prop_output_mode == OUT_F2) + octaveVal = f.y; + else if (prop_output_mode == OUT_F2F1) + octaveVal = clamp(f.y - f.x, 0.0, 1.0); + else + octaveVal = clamp((f.x + f.y) * 0.5, 0.0, 1.0); + + value += octaveVal * amplitude; + totalAmp += amplitude; + freq *= prop_lacunarity; + amplitude *= prop_gain; + } + + float result = clamp(value / totalAmp, 0.0, 1.0); + return vec4(vec3(result), 1.0); + } + )""""; + + this->setShaderSource(source); +} From 7c8b619c10c03253cb9de5130defbd11fe49b754 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 18 May 2026 00:47:44 -0500 Subject: [PATCH 028/164] make voronoi fractal seamless --- .../libraries/v3/voronoifractal.cpp | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/texturelab/libraries/v3/voronoifractal.cpp b/src/texturelab/libraries/v3/voronoifractal.cpp index e1fae002..f4ae6b9e 100644 --- a/src/texturelab/libraries/v3/voronoifractal.cpp +++ b/src/texturelab/libraries/v3/voronoifractal.cpp @@ -9,6 +9,12 @@ // Output modes: F1 = distance to nearest cell (soft blobs), // F2 = second nearest, F2-F1 = cell edges only (crack lines), // F1+F2 = softer combined cells. +// +// Seamless tiling: each octave's frequency is snapped to the nearest integer +// grid size, and cell hashes use mod()-wrapped indices. This makes the +// pattern repeat exactly across UV tile boundaries. Frequencies are +// effectively rounded — at small Scale values (1-3) you may notice slight +// snapping, but the pattern tiles perfectly at every setting. void VoronoiFractalNode::init() { this->title = "Voronoi Fractal"; @@ -26,8 +32,6 @@ void VoronoiFractalNode::init() {"F1", "F2", "F2-F1", "F1+F2"}); outProp->index = 0; - this->addIntProp("seed", "Seed", 0, 0, 999, 1); - auto source = R""""( #define DIST_EUCLIDEAN 0 #define DIST_MANHATTAN 1 @@ -37,8 +41,13 @@ void VoronoiFractalNode::init() #define OUT_F2F1 2 #define OUT_F1F2 3 - vec2 cellPoint(vec2 cell) { - return cell + hash22(cell + vec2(float(prop_seed) * 0.193, float(prop_seed) * 0.457)); + // Cell point at index `cellIdx` on a periodic grid of size `gridSize`. + // The hash uses mod()-wrapped indices so cells at the tile boundary + // (e.g. cell 0 and cell gridSize) share the same random offset — + // the prerequisite for seamless tiling. + vec2 cellPoint(vec2 cellIdx, float gridSize) { + vec2 wrapped = mod(cellIdx, vec2(gridSize)); + return cellIdx + hash22(wrapped + vec2(_seed * 0.193, _seed * 0.457)); } float cellDist(vec2 a, vec2 b) { @@ -48,17 +57,17 @@ void VoronoiFractalNode::init() return length(a - b); // Euclidean } - // Returns (F1, F2) for a given scaled UV - vec2 voronoi(vec2 uv) { - vec2 cell = floor(uv); - vec2 local = fract(uv); + // Returns (F1, F2) for UV sampled on an integer-sized grid. + vec2 voronoi(vec2 uv, float gridSize) { + vec2 sampleUV = uv * gridSize; + vec2 cell = floor(sampleUV); float f1 = 1e9, f2 = 1e9; for (int x = -2; x <= 2; x++) { for (int y = -2; y <= 2; y++) { vec2 nb = vec2(float(x), float(y)); - vec2 pt = cellPoint(cell + nb); - float d = cellDist(uv, pt); + vec2 pt = cellPoint(cell + nb, gridSize); + float d = cellDist(sampleUV, pt); if (d < f1) { f2 = f1; f1 = d; } else if (d < f2) { f2 = d; } } @@ -75,7 +84,9 @@ void VoronoiFractalNode::init() float totalAmp = 0.0; for (int i = 0; i < prop_octaves; i++) { - vec2 f = voronoi(uv * freq); + // Snap frequency to integer grid size for seamless tiling + float gridSize = max(1.0, floor(freq + 0.5)); + vec2 f = voronoi(uv, gridSize); float octaveVal = 0.0; if (prop_output_mode == OUT_F1) From e50859d4c5c1cb9701c067d4b8fe843dc71af54f Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 18 May 2026 01:18:29 -0500 Subject: [PATCH 029/164] add makteittile --- src/texturelab/libraries/v3/makeittile.cpp | 64 ++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/texturelab/libraries/v3/makeittile.cpp diff --git a/src/texturelab/libraries/v3/makeittile.cpp b/src/texturelab/libraries/v3/makeittile.cpp new file mode 100644 index 00000000..5f397c83 --- /dev/null +++ b/src/texturelab/libraries/v3/makeittile.cpp @@ -0,0 +1,64 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// https://iquilezles.org/articles/texturerepetition/ (complementary approach) +// Adobe Substance Designer "Make It Tile" reference behaviour. +// +// Offset-cross-blend tiling: +// 1. Sample the input at uv +// 2. Sample the input again at fract(uv + 0.5) — a half-period shift +// 3. Blend toward the shifted copy near the tile edges; keep the original +// near the centre. +// +// At any tile boundary (uv.x = 0 or 1, uv.y = 0 or 1) the weight is 0 so the +// output reduces to the shifted sample, which evaluates to texture(0.5, *) on +// both sides of the seam — exactly matching across the boundary. +// Tileability is therefore mathematically guaranteed, not approximate. +// +// `Horizontal` and `Vertical` independently toggle which axis is fixed; +// disable either if the input is already tileable in that direction so the +// node leaves it untouched. +void MakeItTileNode::init() +{ + this->title = "Make It Tile"; + + this->addInput("image"); + + this->addFloatProp("blend_width", "Blend Width", 0.2, 0.02, 0.5, 0.01); + this->addBoolProp ("horizontal", "Horizontal", true); + this->addBoolProp ("vertical", "Vertical", true); + + auto source = R""""( + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.0, 0.0, 0.0, 1.0); + + // Sample the original + vec4 orig = texture(image, uv); + + // Build the half-period shift on enabled axes only. + // Disabled axes use 0 shift, which means orig == shifted along + // that axis — the blend collapses to a no-op for that direction. + vec2 shift = vec2(prop_horizontal ? 0.5 : 0.0, + prop_vertical ? 0.5 : 0.0); + vec2 sUV = fract(uv + shift); + vec4 shifted = texture(image, sUV); + + // Distance to the nearest edge on each enabled axis. + // For disabled axes use 1.0 so the axis never reduces minD. + float dx = prop_horizontal ? min(uv.x, 1.0 - uv.x) : 1.0; + float dy = prop_vertical ? min(uv.y, 1.0 - uv.y) : 1.0; + float minD = min(dx, dy); + + // Blend weight: 0 at the seam → use shifted; 1 in centre → use original + float bw = max(prop_blend_width, 0.001); + float w = smoothstep(0.0, bw, minD); + + return mix(shifted, orig, w); + } + )""""; + + this->setShaderSource(source); +} From 24869ee64d2cc4e349f687d4f706fa5cb6fac457 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 18 May 2026 01:30:11 -0500 Subject: [PATCH 030/164] fix diagonal --- src/texturelab/libraries/v3/truchet.cpp | 98 +++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/texturelab/libraries/v3/truchet.cpp diff --git a/src/texturelab/libraries/v3/truchet.cpp b/src/texturelab/libraries/v3/truchet.cpp new file mode 100644 index 00000000..fc344826 --- /dev/null +++ b/src/texturelab/libraries/v3/truchet.cpp @@ -0,0 +1,98 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Smith & Bouvier, "Truchet Tilings Revisited," The Mathematical Intelligencer 1987 +// https://iquilezles.org/articles/truchet/ +// Truchet tile patterns — per-cell randomly oriented arcs or diagonal lines +// that form continuous curved traces across the grid. Used in sci-fi surface +// tech patterns, circuit board traces and alien architecture detail. +// +// Scale is an integer (cells per UV tile), which combined with the Truchet +// edge-midpoint property guarantees seamless UV tiling: every cell edge +// crosses curves at the same midpoint, so adjacent tiles always connect +// smoothly regardless of per-cell orientation. +void TruchetNode::init() +{ + this->title = "Truchet"; + + this->addIntProp ("scale", "Scale", 8, 2, 32, 1); + this->addFloatProp("line_width", "Line Width", 0.08, 0.01, 0.4, 0.01); + + auto variantProp = this->addEnumProp("variant", "Variant", + {"Arc", "Diagonal", "Diagonal Mirrored"}); + variantProp->index = 0; + + auto source = R""""( + #define VARIANT_ARC 0 + #define VARIANT_DIAG 1 + #define VARIANT_DIAGMIR 2 + + // Signed distance from a line segment for anti-aliased lines + float lineSDF(vec2 p, vec2 a, vec2 b) { + vec2 ab = b - a; + vec2 ap = p - a; + float t = clamp(dot(ap, ab) / dot(ab, ab), 0.0, 1.0); + return length(ap - ab * t); + } + + vec4 process(vec2 uv) + { + vec2 scaled = uv * float(prop_scale); + vec2 cell = floor(scaled); + vec2 local = fract(scaled) - 0.5; // centred at (0,0) in [-0.5, 0.5] + + // Random orientation per cell (0 or 1), driven by engine seed + float r = hash12(cell + vec2(_seed * 0.179, _seed * 0.413)); + float orient = step(0.5, r); // 0.0 or 1.0 + + float d = 1.0; + float lw = prop_line_width * 0.5; + float radius = 0.5; + + if (prop_variant == VARIANT_ARC) { + // Two quarter-circle arcs connecting midpoints of adjacent edges + float d1, d2; + if (orient < 0.5) { + d1 = abs(length(local - vec2(-0.5, -0.5)) - radius); + d2 = abs(length(local - vec2( 0.5, 0.5)) - radius); + } else { + d1 = abs(length(local - vec2( 0.5, -0.5)) - radius); + d2 = abs(length(local - vec2(-0.5, 0.5)) - radius); + } + d = min(d1, d2); + } else if (prop_variant == VARIANT_DIAG) { + // Midpoint-to-midpoint diagonals (Smith-Truchet variant). + // Two short segments connect adjacent edge midpoints; every + // cell touches all 4 edge midpoints regardless of orientation, + // so adjacent cells always connect smoothly. + float d1, d2; + if (orient < 0.5) { + // Upper-left and lower-right segments + d1 = lineSDF(local, vec2(-0.5, 0.0), vec2(0.0, 0.5)); + d2 = lineSDF(local, vec2( 0.5, 0.0), vec2(0.0, -0.5)); + } else { + // Lower-left and upper-right segments (mirror) + d1 = lineSDF(local, vec2(-0.5, 0.0), vec2(0.0, -0.5)); + d2 = lineSDF(local, vec2( 0.5, 0.0), vec2(0.0, 0.5)); + } + d = min(d1, d2); + } else { + // Two corner-to-corner diagonals forming an X in every cell. + // The X always touches all 4 corners of the cell, so adjacent + // cells' diagonals always meet at the shared corner. + float d1 = lineSDF(local, vec2(-0.5, -0.5), vec2( 0.5, 0.5)); + float d2 = lineSDF(local, vec2( 0.5, -0.5), vec2(-0.5, 0.5)); + d = min(d1, d2); + } + + // Anti-aliased line with smoothstep + float aa = fwidth(d); + float line = 1.0 - smoothstep(lw - aa, lw + aa, d); + + return vec4(vec3(line), 1.0); + } + )""""; + + this->setShaderSource(source); +} From 3d075b5badb8af64309b2cbb302328d1850c827e Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 18 May 2026 01:34:38 -0500 Subject: [PATCH 031/164] add toon gradient --- src/texturelab/libraries/v3/toongradient.cpp | 48 ++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/texturelab/libraries/v3/toongradient.cpp diff --git a/src/texturelab/libraries/v3/toongradient.cpp b/src/texturelab/libraries/v3/toongradient.cpp new file mode 100644 index 00000000..dde1d002 --- /dev/null +++ b/src/texturelab/libraries/v3/toongradient.cpp @@ -0,0 +1,48 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Toon / posterised gradient — remaps greyscale input to a stepped, +// band-mapped gradient. Each band maps to a colour stop in the gradient +// prop, giving crisp toon-shading zones while preserving artist colour +// control. Critical for R&C-style roughness (3-band) and albedo (4-6 band) +// maps that read as stylised even up close. +void ToonGradientNode::init() +{ + this->title = "Toon Gradient"; + + this->addInput("image"); + + this->addIntProp ("bands", "Bands", 4, 2, 16, 1); + this->addFloatProp("softness", "Edge Softness", 0.05, 0.0, 0.5, 0.01); + this->addGradientProp("gradient", "Gradient", Gradient::defaultGradient()); + + auto source = R""""( + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.0, 0.0, 0.0, 1.0); + + float input = texture(image, uv).r; + float bands = float(prop_bands); + + // Quantise to N bands + float bandIdx = floor(input * bands); + float bandFract = fract(input * bands); + + // Soft transition between bands using smoothstep on the intra-band fraction + float soft = smoothstep(0.0, prop_softness * bands, bandFract) + * (1.0 - smoothstep((1.0 - prop_softness) * bands, + bands, bandFract + bandIdx * 1.0)); + + // Map to [0,1] for gradient sampling + float t = (bandIdx + clamp(soft, 0.0, 1.0)) / bands; + t = clamp(t, 0.0, 1.0); + + vec3 col = sampleGradient(prop_gradient, t); + return vec4(col, 1.0); + } + )""""; + + this->setShaderSource(source); +} From 932a2528010dd4558f18815a85c7684a1721e241 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 18 May 2026 01:40:25 -0500 Subject: [PATCH 032/164] add height blend --- src/texturelab/libraries/v3/heightblend.cpp | 65 +++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/texturelab/libraries/v3/heightblend.cpp diff --git a/src/texturelab/libraries/v3/heightblend.cpp b/src/texturelab/libraries/v3/heightblend.cpp new file mode 100644 index 00000000..147cfff0 --- /dev/null +++ b/src/texturelab/libraries/v3/heightblend.cpp @@ -0,0 +1,65 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// https://blog.selfshadow.com/publications/blending-in-detail/ +// Barré-Brisebois & Bouchard, "Approximating Translucency for a Fast, Cheap +// and Convincing Subsurface-Scattering Look," GDC 2011 +// Height-aware material blend: the top layer wears away at high-height +// features of the bottom layer, revealing it underneath — physically +// plausible layering without the muddy result of linear alpha blending. +// Classic use: paint coat over scratched metal, where scratches cut through. +// Feed Curvature output as the mask for curvature-driven edge wear. +void HeightBlendNode::init() +{ + this->title = "Height Blend"; + + this->addInput("color_a"); // bottom material (e.g. bare metal) + this->addInput("color_b"); // top material (e.g. paint) + this->addInput("height_a"); // height map for bottom material + this->addInput("height_b"); // height map for top material + this->addInput("mask"); // wear mask: white=top present, black=bottom exposed + + this->addFloatProp("blend", "Blend Factor", 0.5, 0.0, 1.0, 0.01); + this->addFloatProp("edge_width", "Edge Width", 0.1, 0.0, 0.5, 0.01); + this->addFloatProp("contrast", "Contrast", 1.0, 0.1, 4.0, 0.1); + + auto source = R""""( + vec4 process(vec2 uv) + { + // Gather inputs; fall back gracefully if not connected + vec4 colA = color_a_connected ? texture(color_a, uv) : vec4(0.2, 0.2, 0.2, 1.0); + vec4 colB = color_b_connected ? texture(color_b, uv) : vec4(0.8, 0.8, 0.8, 1.0); + + float hA = height_a_connected ? texture(height_a, uv).r : 0.5; + float hB = height_b_connected ? texture(height_b, uv).r : 0.5; + float wearMask = mask_connected ? texture(mask, uv).r : prop_blend; + + // wearMask=1 → full top coat; wearMask=0 → fully worn to base + float coverage = clamp(wearMask * prop_blend * 2.0, 0.0, 1.0); + + // Height-based cutoff: find where the two height fields "meet" + float hAscaled = hA * (1.0 - coverage); + float hBscaled = hB * coverage; + float cutoff = max(hAscaled, hBscaled); + + float ew = max(prop_edge_width, 0.001); + + // Per-pixel blend factor from height intersection + float bFactor = smoothstep(cutoff - ew, cutoff + ew, hBscaled); + + // Apply contrast to the blend factor + if (prop_contrast != 1.0) { + bFactor = clamp( + (bFactor - 0.5) * prop_contrast + 0.5, + 0.0, 1.0 + ); + } + + vec4 result = mix(colA, colB, bFactor); + return result; + } + )""""; + + this->setShaderSource(source); +} From d0271871e830e80a1d67bba8491859b40187ed59 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 18 May 2026 02:00:19 -0500 Subject: [PATCH 033/164] add domain warp --- src/texturelab/libraries/v3/fbmdomainwarp.cpp | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/texturelab/libraries/v3/fbmdomainwarp.cpp diff --git a/src/texturelab/libraries/v3/fbmdomainwarp.cpp b/src/texturelab/libraries/v3/fbmdomainwarp.cpp new file mode 100644 index 00000000..fb1926b4 --- /dev/null +++ b/src/texturelab/libraries/v3/fbmdomainwarp.cpp @@ -0,0 +1,96 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// https://iquilezles.org/articles/warp/ +// Quilez, "Domain Warping" — the definitive reference with interactive demos. +// +// fBm where the sampling coordinates are themselves offset by another layer of +// fBm, producing swirling organic turbulence that is impossible to achieve +// with standard layered noise. Warp 1 creates mild swirling; enabling +// Double Warp adds a second recursive step for maximum complexity. +// +// Seamless tiling: the base scale is an integer grid size (cells per UV +// tile), each octave doubles it, and value-noise hashes use mod()-wrapped +// cell indices. Because the warp itself is also seamless (its inputs share +// the same wrapping), warped sampling positions differ by integer amounts +// across the texture boundary — preserving the periodic property end-to-end. +void FBMDomainWarpNode::init() +{ + this->title = "FBM Domain Warp"; + + this->addIntProp ("scale", "Scale", 3, 1, 12, 1); + this->addIntProp ("octaves", "Octaves", 6, 1, 8, 1); + this->addFloatProp("warp", "Primary Warp", 1.0, 0.0, 4.0, 0.1); + this->addFloatProp("warp2", "Secondary Warp", 0.5, 0.0, 4.0, 0.1); + this->addBoolProp ("two_pass", "Double Warp", true); + + auto source = R""""( + // Tileable value noise: cell hashes use mod()-wrapped indices so the + // noise repeats exactly every gridSize cells along each axis. + float vnoise(vec2 sampleUV, float gridSize) { + vec2 i = floor(sampleUV); + vec2 f = fract(sampleUV); + vec2 u = f * f * (3.0 - 2.0 * f); + + vec2 sOff = vec2(_seed * 0.173); + + float a = hash12(mod(i + vec2(0.0, 0.0), vec2(gridSize)) + sOff); + float b = hash12(mod(i + vec2(1.0, 0.0), vec2(gridSize)) + sOff); + float c = hash12(mod(i + vec2(0.0, 1.0), vec2(gridSize)) + sOff); + float d = hash12(mod(i + vec2(1.0, 1.0), vec2(gridSize)) + sOff); + + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); + } + + // Multi-octave fBm. Each octave doubles the grid size (lacunarity 2); + // since baseScale is integer, every octave's grid size remains integer + // → mod() wrapping is exact and the entire fBm is seamlessly tileable. + float fbm(vec2 uv) { + float value = 0.0; + float amp = 0.5; + float gridSize = float(prop_scale); + for (int i = 0; i < prop_octaves; i++) { + value += amp * vnoise(uv * gridSize, gridSize); + gridSize *= 2.0; + amp *= 0.5; + } + return value; + } + + vec4 process(vec2 uv) + { + // Warp pass 1: offset UV by two independent fBm samples. + // The constant offsets (5.2, 1.3) etc. just shift which part of + // the noise we read; they don't affect tileability because mod() + // wrapping handles any sample position. + vec2 q = vec2( + fbm(uv + vec2(0.000, 0.000)), + fbm(uv + vec2(5.200, 1.300)) + ); + + float f; + if (prop_two_pass) { + // Warp pass 2: use q to offset again (recursive swirling). + // Since q is itself seamless, q at uv=0 equals q at uv=1, + // so the warped sampling positions still differ by exactly 1 + // across the tile boundary — fbm's mod() wrap maps them to + // the same noise value. + vec2 r = vec2( + fbm(uv + prop_warp * q + vec2(1.700, 9.200)), + fbm(uv + prop_warp * q + vec2(8.300, 2.800)) + ); + f = fbm(uv + prop_warp2 * r); + } else { + f = fbm(uv + prop_warp * q); + } + + // Remap to [0,1] and add a slight contrast lift + f = clamp(f * 1.4 - 0.1, 0.0, 1.0); + + return vec4(vec3(f), 1.0); + } + )""""; + + this->setShaderSource(source); +} From 8bfdbbbb4fee01802e6f9fce1d1425da9382e311 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 18 May 2026 02:07:07 -0500 Subject: [PATCH 034/164] new batch of nodes --- src/texturelab/CMakeLists.txt | 19 ++++++ src/texturelab/libraries/library.cpp | 28 ++++++++ src/texturelab/libraries/libv3.h | 98 ++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index bb682454..94ef526d 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -113,6 +113,25 @@ set(LIBRARYV3 ./libraries/v3/floodfillv2tobbox.cpp ./libraries/v3/floodfillv2togradient.cpp ./libraries/v3/floodfillv2sampler.cpp + # Phase 1 — Filters / Color + ./libraries/v3/edgedetect.cpp + ./libraries/v3/highpass.cpp + ./libraries/v3/emboss.cpp + ./libraries/v3/vibrance.cpp + ./libraries/v3/colortomask.cpp + ./libraries/v3/toongradient.cpp + ./libraries/v3/autolevels.cpp + # Phase 2 — Generators + ./libraries/v3/directionalscratches.cpp + ./libraries/v3/roughgrain.cpp + ./libraries/v3/voronoifractal.cpp + ./libraries/v3/truchet.cpp + ./libraries/v3/fbmdomainwarp.cpp + # Phase 3 — Multi-pass + ./libraries/v3/blurhq.cpp + ./libraries/v3/distancetransform.cpp + ./libraries/v3/heightblend.cpp + ./libraries/v3/makeittile.cpp ) set(PROJECT_SOURCES diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index 522418b1..f09c8216 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -230,5 +230,33 @@ Library* createLibraryV3() lib->addNode("curve", "Curve", ":nodes/curve.png"); + // ----------------------------------------------------------------------- + // Phase 1 — Filters / Color + // ----------------------------------------------------------------------- + lib->addNode ("edgedetect", "Edge Detect", ":nodes/bevel.png"); + lib->addNode ("highpass", "Highpass", ":nodes/blurv2.png"); + lib->addNode ("emboss", "Emboss", ":nodes/normalmap.png"); + lib->addNode ("vibrance", "Vibrance", ":nodes/hsl.png"); + lib->addNode ("colortomask", "Color To Mask", ":nodes/extractchannel.png"); + lib->addNode ("toongradient", "Toon Gradient", ":nodes/gradientmap.png"); + lib->addNode ("autolevels", "Auto Levels", ":nodes/histogramscan.png"); + + // ----------------------------------------------------------------------- + // Phase 2 — Generators + // ----------------------------------------------------------------------- + lib->addNode("directionalscratches", "Directional Scratches", ":nodes/cell.png"); + lib->addNode ("roughgrain", "Rough Grain", ":nodes/cell.png"); + lib->addNode ("voronoifractal", "Voronoi Fractal", ":nodes/cell.png"); + lib->addNode ("truchet", "Truchet", ":nodes/hexagon.png"); + lib->addNode ("fbmdomainwarp", "FBM Domain Warp", ":nodes/fractalnoise.png"); + + // ----------------------------------------------------------------------- + // Phase 3 — Multi-pass + // ----------------------------------------------------------------------- + lib->addNode ("blurhq", "Blur HQ", ":nodes/blurv2.png"); + lib->addNode ("distancetransform", "Distance Transform", ":nodes/bevel.png"); + lib->addNode ("heightblend", "Height Blend", ":nodes/blend.png"); + lib->addNode ("makeittile", "Make It Tile", ":nodes/tile.png"); + return lib; } \ No newline at end of file diff --git a/src/texturelab/libraries/libv3.h b/src/texturelab/libraries/libv3.h index 38f0de3c..856276f0 100644 --- a/src/texturelab/libraries/libv3.h +++ b/src/texturelab/libraries/libv3.h @@ -4,6 +4,104 @@ #include "v3/curvenode.h" #include +// --------------------------------------------------------------------------- +// Phase 1 — Single-pass filter / color nodes +// --------------------------------------------------------------------------- + +class EdgeDetectNode : public TextureNode { +public: + void init() override; +}; + +class HighpassNode : public TextureNode { +public: + void init() override; +}; + +class EmbossNode : public TextureNode { +public: + void init() override; +}; + +class VibranceNode : public TextureNode { +public: + void init() override; +}; + +class ColorToMaskNode : public TextureNode { +public: + void init() override; +}; + +class ToonGradientNode : public TextureNode { +public: + void init() override; +}; + +class AutoLevelsNode : public TextureNode { +public: + void init() override; + std::shared_ptr createRenderer() override; + std::shared_ptr createRenderData() override; +}; + +// --------------------------------------------------------------------------- +// Phase 2 — Generator nodes +// --------------------------------------------------------------------------- + +class DirectionalScratchesNode : public TextureNode { +public: + void init() override; +}; + +class RoughGrainNode : public TextureNode { +public: + void init() override; +}; + +class VoronoiFractalNode : public TextureNode { +public: + void init() override; +}; + +class TruchetNode : public TextureNode { +public: + void init() override; +}; + +class FBMDomainWarpNode : public TextureNode { +public: + void init() override; +}; + +// --------------------------------------------------------------------------- +// Phase 3 — Multi-pass nodes +// --------------------------------------------------------------------------- + +class BlurHQNode : public TextureNode { +public: + void init() override; + std::shared_ptr createRenderer() override; + std::shared_ptr createRenderData() override; +}; + +class DistanceTransformNode : public TextureNode { +public: + void init() override; + std::shared_ptr createRenderer() override; + std::shared_ptr createRenderData() override; +}; + +class HeightBlendNode : public TextureNode { +public: + void init() override; +}; + +class MakeItTileNode : public TextureNode { +public: + void init() override; +}; + class BevelV2Node : public TextureNode { public: virtual void init() override; From 321f26803dc874cabd46d35d23c578da802dff9a Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 18 May 2026 02:13:24 -0500 Subject: [PATCH 035/164] add curve icon --- public/assets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/assets b/public/assets index 5996a27d..f6969125 160000 --- a/public/assets +++ b/public/assets @@ -1 +1 @@ -Subproject commit 5996a27d943382fabeafb18217f9de212c62d420 +Subproject commit f6969125eb5ed2ad8e33d347e63bb072dcdd1005 From f33207f548ff02281804f75676f33f384835d9d5 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Wed, 20 May 2026 01:41:40 -0500 Subject: [PATCH 036/164] make blurhq seamless --- src/texturelab/libraries/v3/blurhq.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/texturelab/libraries/v3/blurhq.cpp b/src/texturelab/libraries/v3/blurhq.cpp index 95eb4fb1..c992ea61 100644 --- a/src/texturelab/libraries/v3/blurhq.cpp +++ b/src/texturelab/libraries/v3/blurhq.cpp @@ -105,9 +105,9 @@ class BlurHQRenderer : public NodeTextureRenderer { int radius = int(ceil(u_radius)); for (int i = -radius; i <= radius; i++) { - float w = exp(-float(i * i) / twoSigSq); + float w = exp(-float(i * i) / twoSigSq); vec2 offset = %1 * float(i); - result += texture(u_image, uv + offset * step) * w; + result += texture(u_image, fract(uv + offset * step)) * w; totalW += w; } From d9391dee43b63e6adb5cc18c1fd7f3f4af18eb82 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Wed, 20 May 2026 01:56:53 -0500 Subject: [PATCH 037/164] implement file save --- src/texturelab/libraries/library.cpp | 1 + src/texturelab/mainwindow.cpp | 47 +++++++++++- src/texturelab/mainwindow.h | 2 + src/texturelab/models.h | 12 ++-- src/texturelab/project.cpp | 102 +++++++++++++++++++++++++++ src/texturelab/project.h | 1 + 6 files changed, 159 insertions(+), 6 deletions(-) diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index f09c8216..bdff4482 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -12,6 +12,7 @@ TextureNodePtr Library::createNode(QString name) auto& item = items[name]; if (item.name == name) { auto node = item.factoryFunction(); + node->typeName = name; // todo: put this in the appropriate place node->init(); diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 0afdd272..454cd21e 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -252,8 +253,8 @@ void MainWindow::setupMenus() fileMenu->addAction("Open Project", [=]() { this->openProject(); }); fileMenu->addAction("New Project", [=]() { this->newProject(); }); fileMenu->addSeparator(); - fileMenu->addAction("Save", []() {}); - fileMenu->addAction("Save As...", []() {}); + fileMenu->addAction("Save", [=]() { this->saveProject(); }); + fileMenu->addAction("Save As...", [=]() { this->saveProjectAs(); }); fileMenu->addSeparator(); fileMenu->addAction("Edit", []() {}); @@ -432,6 +433,48 @@ void MainWindow::openProject() void MainWindow::newProject() { setProject(TextureProject::createEmpty()); } +void MainWindow::saveProject() +{ + if (project->filePath.isNull() || project->filePath.isEmpty()) { + QString filePath = QFileDialog::getSaveFileName( + this, "Save Texture...", QString(), "Texturelab File (*.texture)"); + + if (filePath.isNull() || filePath.isEmpty()) { + return; + } + + if (!filePath.endsWith(".texture", Qt::CaseInsensitive)) + filePath += ".texture"; + + project->filePath = filePath; + } + + QFile file(project->filePath); + file.open(QIODevice::WriteOnly); + file.write(Project::saveTexture(project)); + file.close(); +} + +void MainWindow::saveProjectAs() +{ + QString filePath = QFileDialog::getSaveFileName( + this, "Save Texture As...", QString(), "Texturelab File (*.texture)"); + + if (filePath.isNull() || filePath.isEmpty()) { + return; + } + + if (!filePath.endsWith(".texture", Qt::CaseInsensitive)) + filePath += ".texture"; + + project->filePath = filePath; + + QFile file(project->filePath); + file.open(QIODevice::WriteOnly); + file.write(Project::saveTexture(project)); + file.close(); +} + void MainWindow::showExportDialog() { if (!this->exportDialog) { diff --git a/src/texturelab/mainwindow.h b/src/texturelab/mainwindow.h index 878dc3ae..25637ee1 100644 --- a/src/texturelab/mainwindow.h +++ b/src/texturelab/mainwindow.h @@ -33,6 +33,8 @@ class MainWindow : public QMainWindow { // menu callbacks void openProject(); void newProject(); + void saveProject(); + void saveProjectAs(); void showExportDialog(); void directExport(); void handleExport(const QString& destination, const QString& pattern); diff --git a/src/texturelab/models.h b/src/texturelab/models.h index 41ceccdc..0aee110d 100644 --- a/src/texturelab/models.h +++ b/src/texturelab/models.h @@ -85,6 +85,7 @@ class TextureProject : public QEnableSharedFromThis { QString exportFilePattern = "${project}_${name}"; QString exportDestination = ""; + QString filePath = nullptr; void addNode(const TextureNodePtr& node); @@ -106,6 +107,7 @@ class TextureProject : public QEnableSharedFromThis { class TextureNode : public QEnableSharedFromThis { public: QString id; + QString typeName; QString title; QVector2D pos; @@ -147,16 +149,18 @@ class TextureNode : public QEnableSharedFromThis { void setShaderSource(const QString& source) { shaderSource = source; } - // Override to provide a custom renderer for multi-pass or non-standard rendering. - // Called on the main thread during queueNextNodeToRender(). + // Override to provide a custom renderer for multi-pass or non-standard + // rendering. Called on the main thread during queueNextNodeToRender(). // Return nullptr for standard single-pass rendering. - virtual std::shared_ptr createRenderer() { + virtual std::shared_ptr createRenderer() + { return nullptr; } // Override to provide render-time data for the custom renderer. // Called on the main thread. Must not reference GPU resources. - virtual std::shared_ptr createRenderData() { + virtual std::shared_ptr createRenderData() + { return nullptr; } diff --git a/src/texturelab/project.cpp b/src/texturelab/project.cpp index 3e01f78a..620e941e 100644 --- a/src/texturelab/project.cpp +++ b/src/texturelab/project.cpp @@ -150,4 +150,106 @@ TextureProjectPtr Project::loadTexture(QString path) texture->library = lib; return texture; +} + +QByteArray Project::saveTexture(TextureProjectPtr texture) +{ + QJsonObject json; + + // nodes + QJsonArray nodeArray; + for (auto& node : texture->nodes) { + QJsonObject nodeDef; + nodeDef["typeName"] = node->typeName; + nodeDef["id"] = node->id; + nodeDef["exportName"] = node->exportName; + nodeDef["randomSeed"] = (double)node->randomSeed; + + QJsonObject propObj; + for (auto key : node->props.keys()) { + propObj[key] = node->props[key]->toJsonValue(); + } + nodeDef["properties"] = propObj; + + nodeArray.append(nodeDef); + } + json["nodes"] = nodeArray; + + // scene (node positions, comments, frames) + QJsonObject sceneObj; + + QJsonObject sceneNodesObj; + for (auto& node : texture->nodes) { + QJsonObject posObj; + posObj["x"] = node->pos.x(); + posObj["y"] = node->pos.y(); + sceneNodesObj[node->id] = posObj; + } + sceneObj["nodes"] = sceneNodesObj; + + QJsonArray commentArray; + for (auto& comment : texture->comments) { + QJsonObject obj; + obj["id"] = comment->id; + obj["text"] = comment->text; + obj["x"] = comment->pos.x(); + obj["y"] = comment->pos.y(); + commentArray.append(obj); + } + sceneObj["comments"] = commentArray; + + QJsonArray frameArray; + for (auto& frame : texture->frames) { + QJsonObject obj; + obj["id"] = frame->id; + obj["title"] = frame->text; + obj["x"] = frame->pos.x(); + obj["y"] = frame->pos.y(); + obj["width"] = frame->size.x(); + obj["height"] = frame->size.y(); + frameArray.append(obj); + } + sceneObj["frames"] = frameArray; + + json["scene"] = sceneObj; + + // connections + QJsonArray conArray; + for (auto& con : texture->connections) { + QJsonObject conObj; + conObj["leftNodeId"] = con->leftNode->id; + conObj["rightNodeId"] = con->rightNode->id; + conObj["rightNodeInput"] = con->rightNodeInputName; + conArray.append(conObj); + } + json["connections"] = conArray; + + // export settings + QJsonObject exportObj; + exportObj["filePattern"] = texture->exportFilePattern; + exportObj["destination"] = texture->exportDestination; + json["export"] = exportObj; + + // editor texture channels + QJsonObject channelsObj; + for (auto it = texture->textureChannels.begin(); + it != texture->textureChannels.end(); ++it) { + QString key; + switch (it.key()) { + case TextureChannel::Albedo: key = "albedo"; break; + case TextureChannel::Normal: key = "normal"; break; + case TextureChannel::Metalness: key = "metalness"; break; + case TextureChannel::Roughness: key = "roughness"; break; + case TextureChannel::Height: key = "height"; break; + case TextureChannel::Alpha: key = "alpha"; break; + case TextureChannel::AO: key = "ao"; break; + default: continue; + } + channelsObj[key] = it.value(); + } + QJsonObject editorObj; + editorObj["textureChannels"] = channelsObj; + json["editor"] = editorObj; + + return QJsonDocument(json).toJson(); } \ No newline at end of file diff --git a/src/texturelab/project.h b/src/texturelab/project.h index 445fba83..bd5ddcda 100644 --- a/src/texturelab/project.h +++ b/src/texturelab/project.h @@ -6,4 +6,5 @@ class Project { public: static TextureProjectPtr loadTexture(QString path); + static QByteArray saveTexture(TextureProjectPtr texture); }; \ No newline at end of file From 67d1948b5712b94192154557438210a7fe6ecf61 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 23 May 2026 02:03:10 -0500 Subject: [PATCH 038/164] fix save position offset --- src/nodegraph/graph/scene.cpp | 5 ++++ src/nodegraph/graph/scene.h | 1 + src/texturelab/mainwindow.cpp | 4 ++++ src/texturelab/widgets/graphwidget.cpp | 32 ++++++++++++++++++++++++++ src/texturelab/widgets/graphwidget.h | 1 + 5 files changed, 43 insertions(+) diff --git a/src/nodegraph/graph/scene.cpp b/src/nodegraph/graph/scene.cpp index 6d8c96fa..1827c265 100644 --- a/src/nodegraph/graph/scene.cpp +++ b/src/nodegraph/graph/scene.cpp @@ -269,6 +269,11 @@ void Node::setCenter(float x, float y) setPos(x - NODE_WIDTH / 2.0f, y - NODE_HEIGHT / 2.0f); } +QPointF Node::getCenter() const +{ + return QPointF(pos().x() + NODE_WIDTH / 2.0f, pos().y() + NODE_HEIGHT / 2.0f); +} + void Node::setName(QString name) { this->name = name; diff --git a/src/nodegraph/graph/scene.h b/src/nodegraph/graph/scene.h index 052e05e0..68a3a8e9 100644 --- a/src/nodegraph/graph/scene.h +++ b/src/nodegraph/graph/scene.h @@ -115,6 +115,7 @@ class Node : public QGraphicsObject, public QEnableSharedFromThis { void setName(QString name); void setCenter(float x, float y); + QPointF getCenter() const; void setThumbnail(const QPixmap& pixmap); void addInPort(QString name); diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 454cd21e..c309e885 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -449,6 +449,8 @@ void MainWindow::saveProject() project->filePath = filePath; } + graphWidget->syncPositionsToModel(); + QFile file(project->filePath); file.open(QIODevice::WriteOnly); file.write(Project::saveTexture(project)); @@ -469,6 +471,8 @@ void MainWindow::saveProjectAs() project->filePath = filePath; + graphWidget->syncPositionsToModel(); + QFile file(project->filePath); file.open(QIODevice::WriteOnly); file.write(Project::saveTexture(project)); diff --git a/src/texturelab/widgets/graphwidget.cpp b/src/texturelab/widgets/graphwidget.cpp index b3946925..4d97f1ea 100644 --- a/src/texturelab/widgets/graphwidget.cpp +++ b/src/texturelab/widgets/graphwidget.cpp @@ -248,6 +248,38 @@ void GraphWidget::addNode(const TextureNodePtr& node) scene->addNode(gnode); } +void GraphWidget::syncPositionsToModel() +{ + if (!project || !scene) + return; + + for (auto& node : project->nodes) { + auto gnode = scene->getNodeById(node->id); + if (gnode) { + auto center = gnode->getCenter(); + node->pos = QVector2D(center.x(), center.y()); + } + } + + for (auto& comment : project->comments) { + auto gcomment = scene->getCommentById(comment->id); + if (gcomment) { + auto p = gcomment->pos(); + comment->pos = QVector2D(p.x(), p.y()); + } + } + + for (auto& frame : project->frames) { + auto gframe = scene->getFrameById(frame->id); + if (gframe) { + auto p = gframe->pos(); + frame->pos = QVector2D(p.x(), p.y()); + auto rect = gframe->frameRect(); + frame->size = QVector2D(rect.width(), rect.height()); + } + } +} + void GraphWidget::dragEnterEvent(QDragEnterEvent* evt) { // qDebug() << "Drag enter"; diff --git a/src/texturelab/widgets/graphwidget.h b/src/texturelab/widgets/graphwidget.h index 597fa034..0ab7e86b 100644 --- a/src/texturelab/widgets/graphwidget.h +++ b/src/texturelab/widgets/graphwidget.h @@ -36,6 +36,7 @@ class GraphWidget : public QMainWindow { void keyPressEvent(QKeyEvent* event) override; void setTextureRenderer(TextureRenderer* renderer); + void syncPositionsToModel(); nodegraph::NodeGraph* graph; // Library* library; From 0dbea3f62f1c94dd5d55d007c50e8d02025caf36 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 23 May 2026 02:12:10 -0500 Subject: [PATCH 039/164] display project name in title --- src/texturelab/mainwindow.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index c309e885..7b0c1c77 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -424,9 +425,9 @@ void MainWindow::openProject() auto project = Project::loadTexture(filePath); - // Extract filename without extension QFileInfo fileInfo(filePath); project->name = fileInfo.baseName(); + project->filePath = filePath; setProject(project); } @@ -471,6 +472,10 @@ void MainWindow::saveProjectAs() project->filePath = filePath; + QFileInfo fileInfo(filePath); + project->name = fileInfo.baseName(); + setWindowTitle(project->name + " - TextureLab"); + graphWidget->syncPositionsToModel(); QFile file(project->filePath); From 69951a50c98d1b58b3a1c0d16b8adfdf094449c3 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 23 May 2026 02:17:29 -0500 Subject: [PATCH 040/164] handle node deletion properly --- src/nodegraph/nodegraph.cpp | 4 +++- src/texturelab/widgets/graphwidget.cpp | 25 +++++++++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/nodegraph/nodegraph.cpp b/src/nodegraph/nodegraph.cpp index 5698895d..b488f241 100644 --- a/src/nodegraph/nodegraph.cpp +++ b/src/nodegraph/nodegraph.cpp @@ -136,7 +136,9 @@ void NodeGraph::keyPressEvent(QKeyEvent* event) for (auto item : items) { if (item->type() == (int)SceneItemType::Node) { auto node = qgraphicsitem_cast(item); - this->_scene->removeNode(node->sharedFromThis()); + auto nodePtr = node->sharedFromThis(); + this->_scene->removeNode(nodePtr); + emit nodeRemoved(nodePtr); } else if (item->type() == (int)SceneItemType::Frame) { auto frame = qgraphicsitem_cast(item); diff --git a/src/texturelab/widgets/graphwidget.cpp b/src/texturelab/widgets/graphwidget.cpp index 4d97f1ea..06a897d6 100644 --- a/src/texturelab/widgets/graphwidget.cpp +++ b/src/texturelab/widgets/graphwidget.cpp @@ -111,11 +111,28 @@ GraphWidget::GraphWidget() : QMainWindow(nullptr) } }); - // connect(graph, &nodegraph::NodeGraph::nodeAdded, - // [=](nodegraph::NodePtr node) { qDebug() << "NODE ADDED"; }); + connect(graph, &nodegraph::NodeGraph::nodeRemoved, + [=](nodegraph::NodePtr node) { + auto nodeId = node->id(); + auto texNode = project->getNodeById(nodeId); + + // remove all connections involving this node from the model + for (auto key : project->connections.keys()) { + auto con = project->connections[key]; + if (con->leftNode->id == nodeId || + con->rightNode->id == nodeId) { + // mark downstream node dirty before disconnecting + if (con->leftNode->id == nodeId) + con->rightNode->isDirty = true; + project->connections.remove(key); + } + } - // connect(graph, &nodegraph::NodeGraph::nodeRemoved, - // [=](nodegraph::NodePtr node) { qDebug() << "NODE REMOVED"; }); + project->nodes.remove(nodeId); + + emit nodeSelectionChanged(TextureNodePtr(nullptr)); + renderer->update(); + }); // library = nullptr; } From 396eea777d0edd14684d594abea3a209cbffb155 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 23 May 2026 03:06:25 -0500 Subject: [PATCH 041/164] fix curvature --- src/texturelab/libraries/v3/curvature.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/texturelab/libraries/v3/curvature.cpp b/src/texturelab/libraries/v3/curvature.cpp index d6b3cc1a..5a872671 100644 --- a/src/texturelab/libraries/v3/curvature.cpp +++ b/src/texturelab/libraries/v3/curvature.cpp @@ -40,7 +40,7 @@ void CurvatureNode::init() return texture(height, p).x; } - float Curve(vec2 p, vec2 o) + float _curveSample(vec2 p, vec2 o) { float a = HeightMap(p + o); float b = HeightMap(p - o); @@ -58,7 +58,7 @@ void CurvatureNode::init() for (float oy = -q; oy < q; oy++) { vec2 o = vec2(ox, oy); - float c = Curve(p, o * s); + float c = _curveSample(p, o * s); v += (H + c) * ((r - length(o * s)) / r); } From 54dbfb3ff2cf3e63ff0213406301d075a2015677 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 23 May 2026 15:39:21 -0500 Subject: [PATCH 042/164] add recent files menu --- src/texturelab/mainwindow.cpp | 50 +++++++++++++++++++++++++++++++++++ src/texturelab/mainwindow.h | 8 ++++++ 2 files changed, 58 insertions(+) diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 7b0c1c77..1df6e012 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include @@ -257,6 +258,12 @@ void MainWindow::setupMenus() fileMenu->addAction("Save", [=]() { this->saveProject(); }); fileMenu->addAction("Save As...", [=]() { this->saveProjectAs(); }); fileMenu->addSeparator(); + + recentFilesMenu = fileMenu->addMenu("Open Recent"); + connect(recentFilesMenu, &QMenu::aboutToShow, + this, &MainWindow::updateRecentFilesMenu); + + fileMenu->addSeparator(); fileMenu->addAction("Edit", []() {}); auto editMenu = this->menuBar()->addMenu("Edit"); @@ -430,6 +437,7 @@ void MainWindow::openProject() project->filePath = filePath; setProject(project); + addToRecentFiles(filePath); } void MainWindow::newProject() { setProject(TextureProject::createEmpty()); } @@ -456,6 +464,7 @@ void MainWindow::saveProject() file.open(QIODevice::WriteOnly); file.write(Project::saveTexture(project)); file.close(); + addToRecentFiles(project->filePath); } void MainWindow::saveProjectAs() @@ -482,6 +491,7 @@ void MainWindow::saveProjectAs() file.open(QIODevice::WriteOnly); file.write(Project::saveTexture(project)); file.close(); + addToRecentFiles(project->filePath); } void MainWindow::showExportDialog() @@ -649,6 +659,46 @@ void MainWindow::handleExport(const QString& destination, QMessageBox::information(this, "Export", message); } +void MainWindow::addToRecentFiles(const QString& filePath) +{ + QSettings settings; + QStringList files = settings.value("recentFiles").toStringList(); + files.removeAll(filePath); + files.prepend(filePath); + while (files.size() > MaxRecentFiles) + files.removeLast(); + settings.setValue("recentFiles", files); +} + +void MainWindow::updateRecentFilesMenu() +{ + recentFilesMenu->clear(); + + QSettings settings; + QStringList files = settings.value("recentFiles").toStringList(); + + for (const QString& filePath : files) { + QFileInfo info(filePath); + auto action = recentFilesMenu->addAction(info.fileName(), [this, filePath]() { + auto project = Project::loadTexture(filePath); + QFileInfo fileInfo(filePath); + project->name = fileInfo.baseName(); + project->filePath = filePath; + setProject(project); + addToRecentFiles(filePath); + }); + action->setToolTip(filePath); + } + + if (files.isEmpty()) + recentFilesMenu->addAction("No recent files")->setEnabled(false); + + recentFilesMenu->addSeparator(); + recentFilesMenu->addAction("Clear Recent Files", [this]() { + QSettings().remove("recentFiles"); + }); +} + MainWindow::~MainWindow() { // Clean up renderer diff --git a/src/texturelab/mainwindow.h b/src/texturelab/mainwindow.h index 25637ee1..eb7d9e1c 100644 --- a/src/texturelab/mainwindow.h +++ b/src/texturelab/mainwindow.h @@ -3,6 +3,8 @@ #include "DockManager.h" #include +#include +#include #include #include @@ -43,12 +45,18 @@ class MainWindow : public QMainWindow { void setProject(TextureProjectPtr project); + void addToRecentFiles(const QString& filePath); + void updateRecentFilesMenu(); + ads::CDockAreaWidget* addDock(const QString& title, ads::DockWidgetArea area, QWidget* widget, ads::CDockAreaWidget* areaWidget); private: + static constexpr int MaxRecentFiles = 10; + ads::CDockManager* dockManager; + QMenu* recentFilesMenu; QToolBar* toolBar; QWidget* editor; From 63387db6f10887acf804e854eae5003201b0ecc2 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 23 May 2026 16:49:18 -0500 Subject: [PATCH 043/164] properly cleanup projects --- src/texturelab/mainwindow.cpp | 6 +++++- src/texturelab/widgets/view2dwidget.cpp | 13 +++++++++++-- src/viewer3d/viewer3d.cpp | 6 ++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 1df6e012..fbde8fc8 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -184,9 +184,13 @@ void MainWindow::passTextureChannelsToViewer3D() void MainWindow::setProject(TextureProjectPtr project) { + // Clear widget state from the old project + this->view2DWidget->clearSelection(); + this->view3DWidget->viewer->clearTextures(); + this->view3DWidget->reRender(); + // Clean up old renderer before creating new one if (this->renderer) { - // Clear references to old renderer in widgets this->graphWidget->setTextureRenderer(nullptr); this->view2DWidget->setTextureRenderer(nullptr); diff --git a/src/texturelab/widgets/view2dwidget.cpp b/src/texturelab/widgets/view2dwidget.cpp index 450327a7..db05c85a 100644 --- a/src/texturelab/widgets/view2dwidget.cpp +++ b/src/texturelab/widgets/view2dwidget.cpp @@ -81,7 +81,11 @@ void View2DWidget::setSelectedNode(const TextureNodePtr& node) this->graph->setSelectedNode(node); } -void View2DWidget::clearSelection() {} +void View2DWidget::clearSelection() +{ + this->node.reset(); + this->graph->clearSelection(); +} void View2DWidget::reRenderNode() { @@ -349,7 +353,12 @@ void View2DGraph::setSelectedNode(const TextureNodePtr& node) void View2DGraph::updatePreview() { this->preview->update(); } -void View2DGraph::clearSelection() {}; +void View2DGraph::clearSelection() +{ + this->preview->clearNode(); + this->preview->hide(); + this->preview->update(); +}; void View2DGraph::drawBackground(QPainter* painter, const QRectF& r) { diff --git a/src/viewer3d/viewer3d.cpp b/src/viewer3d/viewer3d.cpp index b866c41c..e14f0582 100644 --- a/src/viewer3d/viewer3d.cpp +++ b/src/viewer3d/viewer3d.cpp @@ -466,6 +466,7 @@ void Viewer3D::setAlbedoTexture(GLuint texId) void Viewer3D::clearAlbedoTexture() { + if (!this->material) return; this->material->albedoMapId = 0; this->material->needsUpdate = true; } @@ -478,6 +479,7 @@ void Viewer3D::setNormalTexture(GLuint texId) void Viewer3D::clearNormalTexture() { + if (!this->material) return; this->material->normalMapId = 0; this->material->needsUpdate = true; } @@ -490,6 +492,7 @@ void Viewer3D::setMetalnessTexture(GLuint texId) void Viewer3D::clearMetalnessTexture() { + if (!this->material) return; this->material->metalnessMapId = 0; this->material->needsUpdate = true; } @@ -502,6 +505,7 @@ void Viewer3D::setRoughnessTexture(GLuint texId) void Viewer3D::clearRoughnessTexture() { + if (!this->material) return; this->material->roughnessMapId = 0; this->material->needsUpdate = true; } @@ -514,6 +518,7 @@ void Viewer3D::setHeightTexture(GLuint texId) void Viewer3D::clearHeightTexture() { + if (!this->material) return; this->material->heightMapId = 0; this->material->needsUpdate = true; } @@ -532,6 +537,7 @@ void Viewer3D::setAoTexture(GLuint texId) void Viewer3D::clearAoTexture() { + if (!this->material) return; this->material->aoMapId = 0; this->material->needsUpdate = true; } From 95c15bf211cac85eb83e0dbbc9fb2a5e315c4093 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 23 May 2026 16:49:50 -0500 Subject: [PATCH 044/164] make offset 0 in tilesmapler by default --- src/texturelab/libraries/v2/tilesampler.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/texturelab/libraries/v2/tilesampler.cpp b/src/texturelab/libraries/v2/tilesampler.cpp index 99f77d09..c36239f8 100644 --- a/src/texturelab/libraries/v2/tilesampler.cpp +++ b/src/texturelab/libraries/v2/tilesampler.cpp @@ -18,7 +18,7 @@ void TileSamplerNode::init() this->addIntProp("columns", "Column Count", 8, 0, 15, 1); auto posProps = this->createGroup("Position"); - posProps->add(this->addFloatProp("offset", "Offset", 0.5, 0, 1, 0.1)); + posProps->add(this->addFloatProp("offset", "Offset", 0.0, 0, 1, 0.1)); posProps->add( this->addEnumProp("offset_axis", "Offset Axis", {"X Axis", "Y Axis"})); posProps->add( From 62233059818e4dc2e1efcd0a404f1b40db60b147 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 23 May 2026 18:13:22 -0500 Subject: [PATCH 045/164] make blur scale invariant --- src/texturelab/libraries/v3/blurhq.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/texturelab/libraries/v3/blurhq.cpp b/src/texturelab/libraries/v3/blurhq.cpp index c992ea61..19d9f5d6 100644 --- a/src/texturelab/libraries/v3/blurhq.cpp +++ b/src/texturelab/libraries/v3/blurhq.cpp @@ -98,12 +98,15 @@ class BlurHQRenderer : public NodeTextureRenderer { vec2 uv = v_texCoord; vec2 step = 1.0 / _textureSize; - float sigma = max(u_radius / 3.0, 0.001); + // Scale radius by resolution so the blur covers the same + // visual proportion regardless of texture size (512 = reference). + float pixelRadius = u_radius * (_textureSize.x / 512.0); + float sigma = max(pixelRadius / 3.0, 0.001); float twoSigSq = 2.0 * sigma * sigma; vec4 result = vec4(0.0); float totalW = 0.0; - int radius = int(ceil(u_radius)); + int radius = int(ceil(pixelRadius)); for (int i = -radius; i <= radius; i++) { float w = exp(-float(i * i) / twoSigSq); vec2 offset = %1 * float(i); From a22bdfc22c496e76b11757b40327833c7e50baf4 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 23 May 2026 18:17:29 -0500 Subject: [PATCH 046/164] render textures using linear sampling --- src/viewer3d/renderer/renderer.cpp | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/viewer3d/renderer/renderer.cpp b/src/viewer3d/renderer/renderer.cpp index ff5a1133..31981bc1 100644 --- a/src/viewer3d/renderer/renderer.cpp +++ b/src/viewer3d/renderer/renderer.cpp @@ -299,31 +299,37 @@ void Renderer::renderGltfMesh(Mesh* mesh, Material* material, shader->setUniformValue("u_EmissiveUVSet", 0); shader->setUniformValue("u_MetallicRoughnessUVSet", 0); + auto bindLinear = [&](GLuint texId) { + gl->glBindTexture(GL_TEXTURE_2D, texId); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + }; + shader->setUniformValue("u_BaseColorSampler", 0); gl->glActiveTexture(GL_TEXTURE0); - gl->glBindTexture(GL_TEXTURE_2D, mat->albedoMapId); + bindLinear(mat->albedoMapId); shader->setUniformValue("u_NormalSampler", 1); gl->glActiveTexture(GL_TEXTURE1); - gl->glBindTexture(GL_TEXTURE_2D, mat->normalMapId); + bindLinear(mat->normalMapId); shader->setUniformValue("u_MetalnessSampler", 2); gl->glActiveTexture(GL_TEXTURE2); - gl->glBindTexture(GL_TEXTURE_2D, mat->metalnessMapId); + bindLinear(mat->metalnessMapId); shader->setUniformValue("u_RoughnessSampler", 3); gl->glActiveTexture(GL_TEXTURE3); - gl->glBindTexture(GL_TEXTURE_2D, mat->roughnessMapId); + bindLinear(mat->roughnessMapId); shader->setUniformValue("u_HeightSampler", 4); gl->glActiveTexture(GL_TEXTURE4); - gl->glBindTexture(GL_TEXTURE_2D, mat->heightMapId); + bindLinear(mat->heightMapId); shader->setUniformValue("u_HeightScale", material->heightScale); shader->setUniformValue("u_OcclusionSampler", 5); gl->glActiveTexture(GL_TEXTURE5); - gl->glBindTexture(GL_TEXTURE_2D, mat->aoMapId); + bindLinear(mat->aoMapId); shader->setUniformValue("u_OcclusionUVSet", 0); shader->setUniformValue("u_OcclusionStrength", 1.0f); From cb21e321e0d31169024725c8a6077d41ad95349d Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 23 May 2026 21:41:32 -0500 Subject: [PATCH 047/164] add alpha and bump mesh detail --- src/viewer3d/renderer/renderer.cpp | 2 +- src/viewer3d/viewer3d.cpp | 38 ++++++++++++++++++++---------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/viewer3d/renderer/renderer.cpp b/src/viewer3d/renderer/renderer.cpp index 31981bc1..6784d804 100644 --- a/src/viewer3d/renderer/renderer.cpp +++ b/src/viewer3d/renderer/renderer.cpp @@ -110,7 +110,7 @@ void Renderer::updateMaterial(Material* material) flags << "ALPHAMODE_OPAQUE 0"; flags << "ALPHAMODE_MASK 1"; flags << "ALPHAMODE_BLEND 2"; - flags << "ALPHAMODE ALPHAMODE_OPAQUE"; + flags << "ALPHAMODE ALPHAMODE_BLEND"; // tone mapping (match 3js as much as we can) flags << "TONEMAP_ACES_HILL 1"; diff --git a/src/viewer3d/viewer3d.cpp b/src/viewer3d/viewer3d.cpp index e14f0582..5ea8175b 100644 --- a/src/viewer3d/viewer3d.cpp +++ b/src/viewer3d/viewer3d.cpp @@ -145,8 +145,11 @@ void Viewer3D::paintGL() } // render gltf mesh + gl->glEnable(GL_BLEND); + gl->glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); renderer->renderGltfMesh(gltfMesh, material, camPos, worldMatrix, viewMatrix, projMatrix); + gl->glDisable(GL_BLEND); // test several in a row // int totalSpheres = 6; @@ -466,7 +469,8 @@ void Viewer3D::setAlbedoTexture(GLuint texId) void Viewer3D::clearAlbedoTexture() { - if (!this->material) return; + if (!this->material) + return; this->material->albedoMapId = 0; this->material->needsUpdate = true; } @@ -479,7 +483,8 @@ void Viewer3D::setNormalTexture(GLuint texId) void Viewer3D::clearNormalTexture() { - if (!this->material) return; + if (!this->material) + return; this->material->normalMapId = 0; this->material->needsUpdate = true; } @@ -492,7 +497,8 @@ void Viewer3D::setMetalnessTexture(GLuint texId) void Viewer3D::clearMetalnessTexture() { - if (!this->material) return; + if (!this->material) + return; this->material->metalnessMapId = 0; this->material->needsUpdate = true; } @@ -505,7 +511,8 @@ void Viewer3D::setRoughnessTexture(GLuint texId) void Viewer3D::clearRoughnessTexture() { - if (!this->material) return; + if (!this->material) + return; this->material->roughnessMapId = 0; this->material->needsUpdate = true; } @@ -518,7 +525,8 @@ void Viewer3D::setHeightTexture(GLuint texId) void Viewer3D::clearHeightTexture() { - if (!this->material) return; + if (!this->material) + return; this->material->heightMapId = 0; this->material->needsUpdate = true; } @@ -537,7 +545,8 @@ void Viewer3D::setAoTexture(GLuint texId) void Viewer3D::clearAoTexture() { - if (!this->material) return; + if (!this->material) + return; this->material->aoMapId = 0; this->material->needsUpdate = true; } @@ -582,27 +591,30 @@ void Viewer3D::setModel(const QString& modelType) // Create new mesh based on type if (modelType == "sphere") { - gltfMesh = createSphere(this->gl, 2, 64, 64); + gltfMesh = createSphere(this->gl, 2, 1000, 1000); } else if (modelType == "plane_xy") { // Create a subdivided plane in XY orientation - gltfMesh = createPlane(this->gl, 4, 4, 32, 32, PlaneOrientation::XY); + gltfMesh = + createPlane(this->gl, 4, 4, 1000, 1000, PlaneOrientation::XY); } else if (modelType == "plane_yz") { // Create a subdivided plane in YZ orientation - gltfMesh = createPlane(this->gl, 4, 4, 32, 32, PlaneOrientation::YZ); + gltfMesh = + createPlane(this->gl, 4, 4, 1000, 1000, PlaneOrientation::YZ); } else if (modelType == "plane_xz") { // Create a subdivided plane in XZ orientation - gltfMesh = createPlane(this->gl, 4, 4, 32, 32, PlaneOrientation::XZ); + gltfMesh = + createPlane(this->gl, 4, 4, 1000, 1000, PlaneOrientation::XZ); } else if (modelType == "cylinder") { // Create a cylinder with height subdivisions for displacement mapping - gltfMesh = createCylinder(this->gl, 1, 1, 2, 32, 32, false); + gltfMesh = createCylinder(this->gl, 1, 1, 2, 1000, 1000, false); } else if (modelType == "cube") { // Create a subdivided cube - gltfMesh = createCube(this->gl, 2, 2, 2, 32, 32, 32); + gltfMesh = createCube(this->gl, 2, 2, 2, 1000, 1000, 1000); } else if (modelType == "cubesphere") { // CubeSphere - a sphere with low segments for a more cubic look @@ -610,7 +622,7 @@ void Viewer3D::setModel(const QString& modelType) } else { // Default to sphere - gltfMesh = createSphere(this->gl, 2, 64, 64); + gltfMesh = createSphere(this->gl, 2, 1000, 1000); } // Release OpenGL context From 3a9c92ef0cea3492d7335fab5fea0074b8857b0b Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 23 May 2026 21:41:39 -0500 Subject: [PATCH 048/164] fix tiling default props --- src/texturelab/libraries/v1/tile.cpp | 4 ++-- src/texturelab/libraries/v2/tilesampler.cpp | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/texturelab/libraries/v1/tile.cpp b/src/texturelab/libraries/v1/tile.cpp index 95295778..ee890a2d 100644 --- a/src/texturelab/libraries/v1/tile.cpp +++ b/src/texturelab/libraries/v1/tile.cpp @@ -14,8 +14,8 @@ void TileNode::init() this->addFloatProp("brickWidth", "Tile Width", 1.0, 0, 1, 0.01); this->addFloatProp("brickHeight", "Tile Height", 1.0, 0, 1, 0.01); - this->addFloatProp("rows", "Rows", 6, 1, 20, 1); - this->addFloatProp("columns", "Columns", 6, 1, 20, 1); + this->addIntProp("rows", "Rows", 6, 1, 30, 1); + this->addIntProp("columns", "Columns", 6, 1, 30, 1); auto source = R""""( // offset for alternating rows diff --git a/src/texturelab/libraries/v2/tilesampler.cpp b/src/texturelab/libraries/v2/tilesampler.cpp index c36239f8..88aa7408 100644 --- a/src/texturelab/libraries/v2/tilesampler.cpp +++ b/src/texturelab/libraries/v2/tilesampler.cpp @@ -14,15 +14,15 @@ void TileSamplerNode::init() this->addEnumProp("blendType", "Blend Type", {"Max", "Add"}); - this->addIntProp("rows", "Row Count", 8, 0, 15, 1); - this->addIntProp("columns", "Column Count", 8, 0, 15, 1); + this->addIntProp("rows", "Row Count", 8, 0, 30, 1); + this->addIntProp("columns", "Column Count", 8, 0, 30, 1); auto posProps = this->createGroup("Position"); posProps->add(this->addFloatProp("offset", "Offset", 0.0, 0, 1, 0.1)); posProps->add( this->addEnumProp("offset_axis", "Offset Axis", {"X Axis", "Y Axis"})); posProps->add( - this->addIntProp("offset_interval", "Offset Interval", 1, 1, 5, 1)); + this->addIntProp("offset_interval", "Offset Interval", 2, 1, 5, 1)); auto rotProps = this->createGroup("Rotation"); rotProps->add(this->addFloatProp("rot", "Rotation", 0, 0, 360, 0.1)); From 458b41e83db854f618de2bea0970c02af61732a9 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 23 May 2026 22:14:30 -0500 Subject: [PATCH 049/164] make bevelv2 scale invariant --- src/texturelab/libraries/v3/bevelv2.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/texturelab/libraries/v3/bevelv2.cpp b/src/texturelab/libraries/v3/bevelv2.cpp index b7c062dd..9add044d 100644 --- a/src/texturelab/libraries/v3/bevelv2.cpp +++ b/src/texturelab/libraries/v3/bevelv2.cpp @@ -194,7 +194,9 @@ class BevelV2Renderer : public NodeTextureRenderer { float dist = length(uv - data.xy) * max(_textureSize.x, _textureSize.y); - float t = clamp(dist / u_distance, 0.0, 1.0); + float pixelDistance = u_distance + * (max(_textureSize.x, _textureSize.y) / 512.0); + float t = clamp(dist / pixelDistance, 0.0, 1.0); float bevel; if (u_shape == SHAPE_ROUND) { From ab041469cc7b2c71407b072efdd184bdbe75d848 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 23 May 2026 22:26:49 -0500 Subject: [PATCH 050/164] add new hdri --- public/assets | 2 +- src/texturelab/assets.qrc | 1 + src/texturelab/widgets/view3dwidget.cpp | 2 +- src/viewer3d/viewer3d.cpp | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/public/assets b/public/assets index f6969125..f4592af3 160000 --- a/public/assets +++ b/public/assets @@ -1 +1 @@ -Subproject commit f6969125eb5ed2ad8e33d347e63bb072dcdd1005 +Subproject commit f4592af3f371674831f2c86178f16f0cf431d2f9 diff --git a/src/texturelab/assets.qrc b/src/texturelab/assets.qrc index efbca3da..adf69b01 100644 --- a/src/texturelab/assets.qrc +++ b/src/texturelab/assets.qrc @@ -106,6 +106,7 @@ ../../public/assets/env/spruit_sunrise/spruit_sunrise_1k.hdr ../../public/assets/env/studio_small_07/studio_small_07_1k.hdr ../../public/assets/env/wide_street_01_1k.hdr + ../../public/assets/env/sunny_rose_garden_1k.hdr ../../public/assets/examples/Copper.texture diff --git a/src/texturelab/widgets/view3dwidget.cpp b/src/texturelab/widgets/view3dwidget.cpp index e0416070..5c84b389 100644 --- a/src/texturelab/widgets/view3dwidget.cpp +++ b/src/texturelab/widgets/view3dwidget.cpp @@ -9,7 +9,7 @@ View3DWidget::View3DWidget() this->viewer = new Viewer3D(); this->setCentralWidget(viewer); - this->viewer->setDefaultEnvironment(":env/cave_wall_1k.hdr"); + this->viewer->setDefaultEnvironment(":env/sunny_rose_garden_1k.hdr"); // Create menu bar QMenuBar* menuBar = new QMenuBar(this); diff --git a/src/viewer3d/viewer3d.cpp b/src/viewer3d/viewer3d.cpp index 5ea8175b..5880da4e 100644 --- a/src/viewer3d/viewer3d.cpp +++ b/src/viewer3d/viewer3d.cpp @@ -111,7 +111,7 @@ void Viewer3D::initializeGL() if (!defaultEnvPath.isEmpty()) renderer->loadEnvironment(defaultEnvPath); else - renderer->loadEnvironment(":assets/panorama.hdr"); + renderer->loadEnvironment(":env/sunny_rose_garden_1k.hdr"); } void Viewer3D::setDefaultEnvironment(const QString path) From febb8624f16c7eb7593646229d69f47f5b614d26 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 23 May 2026 22:59:42 -0500 Subject: [PATCH 051/164] increase sphere generation perf --- src/viewer3d/geometry/sphere.cpp | 216 +++++++++++++++---------------- src/viewer3d/viewer3d.cpp | 2 +- 2 files changed, 107 insertions(+), 111 deletions(-) diff --git a/src/viewer3d/geometry/sphere.cpp b/src/viewer3d/geometry/sphere.cpp index 26a7ff69..1d59e65a 100644 --- a/src/viewer3d/geometry/sphere.cpp +++ b/src/viewer3d/geometry/sphere.cpp @@ -4,7 +4,8 @@ #include #include #include -#include +#include +#include #define BUFFER_OFFSET(i) ((char*)NULL + (i)) @@ -12,155 +13,150 @@ Mesh* createSphere(QOpenGLFunctions* gl, float radius, int widthSegments, int heightSegments, float phiStart, float phiLength, float thetaStart, float thetaLength) { - const float uvScaleX = 2; - const float uvScaleY = 1; + const float uvScaleX = 2.0f; + const float uvScaleY = 1.0f; - widthSegments = std::max(3.0, std::floor(widthSegments)); - heightSegments = std::max(2.0, std::floor(heightSegments)); + widthSegments = std::max(3, widthSegments); + heightSegments = std::max(2, heightSegments); - auto thetaEnd = std::min((double)thetaStart + thetaLength, M_PI); + const double thetaEnd = std::min((double)thetaStart + thetaLength, M_PI); - int index = 0; - QVector> grid; + const int ringCount = heightSegments + 1; + const int colCount = widthSegments + 1; + const int vertexCount = ringCount * colCount; - QVector3D vertex; - - // buffers - QVector indices; - QVector vertices; - QVector normals; - QVector tangents; - QVector uvs; - - for (int iy = 0; iy <= heightSegments; iy++) { - QVector verticesRow; - - auto v = iy / (float)heightSegments; - - auto uOffset = 0; - if (iy == 0 && thetaStart == 0) { - - uOffset = 0.5 / widthSegments; - } - else if (iy == heightSegments && thetaEnd == M_PI) { - - uOffset = -0.5 / widthSegments; - } - - for (int ix = 0; ix <= widthSegments; ix++) { - - auto u = ix / (float)widthSegments; - - // vertex - auto phi = phiStart + u * phiLength; - auto theta = thetaStart + v * thetaLength; - - auto x = -radius * std::cos(phi) * std::sin(theta); - auto y = radius * std::cos(theta); - auto z = radius * std::sin(phi) * std::sin(theta); - - vertices.append({x, y, z}); - - // normal - QVector3D normal(x, y, z); - normal.normalize(); - normals.append({normal.x(), normal.y(), normal.z()}); - - // tangent (derivative of position with respect to phi) - QVector3D tangent(std::sin(phi), 0.0f, std::cos(phi)); - tangent.normalize(); - tangents.append({tangent.x(), tangent.y(), tangent.z(), 1.0f}); - - // uv - - uvs.append({(u + uOffset) * uvScaleX, (1 - v) * uvScaleY}); + // Precompute trig per column (phi) and per ring (theta) to avoid + // redundant sin/cos calls inside the double loop. + std::vector sinPhi(colCount), cosPhi(colCount); + for (int ix = 0; ix < colCount; ix++) { + float phi = phiStart + (ix / (float)widthSegments) * phiLength; + sinPhi[ix] = std::sin(phi); + cosPhi[ix] = std::cos(phi); + } + std::vector sinTheta(ringCount), cosTheta(ringCount); + for (int iy = 0; iy < ringCount; iy++) { + float theta = thetaStart + (iy / (float)heightSegments) * thetaLength; + sinTheta[iy] = std::sin(theta); + cosTheta[iy] = std::cos(theta); + } - verticesRow.append(index++); + // Interleaved layout per vertex: pos(3) normal(3) uv(2) tangent(4) = 12 floats + static constexpr int kFloatsPerVertex = 12; + static constexpr int kStride = kFloatsPerVertex * sizeof(float); + + std::vector interleaved; + interleaved.reserve(vertexCount * kFloatsPerVertex); + + // Upper-bound index count: 6 per quad + std::vector indices; + indices.reserve(widthSegments * heightSegments * 6); + + for (int iy = 0; iy < ringCount; iy++) { + const float v = iy / (float)heightSegments; + const float sinT = sinTheta[iy]; + const float cosT = cosTheta[iy]; + + float uOffset = 0.0f; + if (iy == 0 && thetaStart == 0.0f) + uOffset = 0.5f / widthSegments; + else if (iy == heightSegments && thetaEnd == M_PI) + uOffset = -0.5f / widthSegments; + + for (int ix = 0; ix < colCount; ix++) { + const float u = ix / (float)widthSegments; + const float sP = sinPhi[ix]; + const float cP = cosPhi[ix]; + + // Position + const float x = -radius * cP * sinT; + const float y = radius * cosT; + const float z = radius * sP * sinT; + + interleaved.push_back(x); + interleaved.push_back(y); + interleaved.push_back(z); + + // Normal = position / radius (already unit length for a sphere) + interleaved.push_back(x / radius); + interleaved.push_back(y / radius); + interleaved.push_back(z / radius); + + // UV + interleaved.push_back((u + uOffset) * uvScaleX); + interleaved.push_back((1.0f - v) * uvScaleY); + + // Tangent = d(pos)/d(phi) normalized = (sin(phi), 0, cos(phi), 1) + // Already unit length: sqrt(sin²+cos²) = 1, no normalize needed. + interleaved.push_back(sP); + interleaved.push_back(0.0f); + interleaved.push_back(cP); + interleaved.push_back(1.0f); } - - grid.append(verticesRow); } + // Build indices with flat grid math — no 2D intermediate vector needed. for (int iy = 0; iy < heightSegments; iy++) { - for (int ix = 0; ix < widthSegments; ix++) { - - auto a = grid[iy][ix + 1]; - auto b = grid[iy][ix]; - auto c = grid[iy + 1][ix]; - auto d = grid[iy + 1][ix + 1]; - - if (iy != 0 || thetaStart > 0) - indices.append({a, b, d}); - if (iy != heightSegments - 1 || thetaEnd < M_PI) - indices.append({b, c, d}); + const unsigned int a = iy * colCount + ix + 1; + const unsigned int b = iy * colCount + ix; + const unsigned int c = (iy + 1) * colCount + ix; + const unsigned int d = (iy + 1) * colCount + ix + 1; + + if (iy != 0 || thetaStart > 0.0f) { + indices.push_back(a); + indices.push_back(b); + indices.push_back(d); + } + if (iy != heightSegments - 1 || thetaEnd < M_PI) { + indices.push_back(b); + indices.push_back(c); + indices.push_back(d); + } } } - // build QOpenGLVertexArrayObject* vao = new QOpenGLVertexArrayObject(); vao->create(); vao->bind(); - QOpenGLBuffer* vbo; - - // position - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); + auto vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); vbo->create(); vbo->bind(); vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(vertices.data(), vertices.length() * sizeof(float)); + vbo->allocate(interleaved.data(), (int)(interleaved.size() * sizeof(float))); + gl->glEnableVertexAttribArray((int)VertexUsage::Position); gl->glVertexAttribPointer((int)VertexUsage::Position, 3, GL_FLOAT, GL_FALSE, - 3 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(0)); - // normal - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(normals.data(), normals.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::Normal); gl->glVertexAttribPointer((int)VertexUsage::Normal, 3, GL_FLOAT, GL_FALSE, - 3 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(3 * sizeof(float))); - // uv - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(uvs.data(), uvs.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::TexCoord0); - gl->glVertexAttribPointer((int)VertexUsage::TexCoord0, 2, GL_FLOAT, - GL_FALSE, 2 * sizeof(float), BUFFER_OFFSET(0)); + gl->glVertexAttribPointer((int)VertexUsage::TexCoord0, 2, GL_FLOAT, GL_FALSE, + kStride, BUFFER_OFFSET(6 * sizeof(float))); - // tangent - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(tangents.data(), tangents.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::Tangent); gl->glVertexAttribPointer((int)VertexUsage::Tangent, 4, GL_FLOAT, GL_FALSE, - 4 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(8 * sizeof(float))); vao->release(); - // indices auto ibo = new QOpenGLBuffer(QOpenGLBuffer::IndexBuffer); ibo->create(); ibo->bind(); ibo->setUsagePattern(QOpenGLBuffer::StaticDraw); - ibo->allocate(indices.data(), indices.length() * sizeof(unsigned int)); + ibo->allocate(indices.data(), (int)(indices.size() * sizeof(unsigned int))); auto mesh = new Mesh(); - mesh->vao = vao; - mesh->meshType = MeshType::Generated; - mesh->indexBuffer = ibo; - mesh->numElements = indices.count(); + mesh->vao = vao; + mesh->meshType = MeshType::Generated; + mesh->indexBuffer = ibo; + mesh->numElements = (int)indices.size(); mesh->indexByteOffset = 0; - mesh->indexType = GL_UNSIGNED_INT; + mesh->indexType = GL_UNSIGNED_INT; mesh->primitiveMode = GL_TRIANGLES; return mesh; -} \ No newline at end of file +} diff --git a/src/viewer3d/viewer3d.cpp b/src/viewer3d/viewer3d.cpp index 5880da4e..cf1faf6f 100644 --- a/src/viewer3d/viewer3d.cpp +++ b/src/viewer3d/viewer3d.cpp @@ -85,7 +85,7 @@ void Viewer3D::initializeGL() mat->roughness = 0.5; // Three.js default mat->metalness = 0.0; // Three.js default // gltfMesh = loadMeshFromRc(":assets/cube.gltf"); - gltfMesh = createSphere(this->gl, 2, 64, 64); + gltfMesh = createSphere(this->gl, 2, 1000, 1000); this->material = mat; // Create skydome for rendering environment From 353f88d53bc11be48cb3a8184346b9776e765052 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 23 May 2026 23:23:11 -0500 Subject: [PATCH 052/164] remove original bevel from v3 lib --- src/texturelab/libraries/library.cpp | 63 ++++++++++++++++------------ 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index bdff4482..983e2f4b 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -198,25 +198,24 @@ Library* createLibraryV3() lib->items.remove("floodfilltogradient"); lib->items.remove("floodfilltorandomcolor"); lib->items.remove("floodfilltorandomintensity"); + lib->items.remove("bevel"); // V3 NODES lib->addNode("bevelv2", "Bevel V2", ":nodes/bevel.png"); lib->addNode("ambientocclusion", "Ambient Occlusion", ":nodes/bevel.png"); - lib->addNode("curvature", "Curvature", - ":nodes/bevel.png"); + lib->addNode("curvature", "Curvature", ":nodes/bevel.png"); lib->addNode("maskedblur", "Masked Blur", - ":nodes/blurv2.png"); + ":nodes/blurv2.png"); lib->addNode("rays", "Rays", ":nodes/bevel.png"); lib->addNode("swirl", "Swirl", ":nodes/bevel.png"); lib->addNode("floodfillv2", "Flood Fill V2", - ":nodes/floodfill.png"); - lib->addNode("floodfillv2tocolor", - "FF To Color V2", + ":nodes/floodfill.png"); + lib->addNode("floodfillv2tocolor", "FF To Color V2", ":nodes/floodfilltocolor.png"); - lib->addNode("floodfillv2torandomcolor", - "FF To Random Color V2", - ":nodes/floodfilltorandomcolor.png"); + lib->addNode( + "floodfillv2torandomcolor", "FF To Random Color V2", + ":nodes/floodfilltorandomcolor.png"); lib->addNode( "floodfillv2torandomintensity", "FF To Random Intensity V2", ":nodes/floodfilltorandomintensity.png"); @@ -225,8 +224,7 @@ Library* createLibraryV3() lib->addNode("floodfillv2togradient", "FF To Gradient V2", ":nodes/floodfilltogradient.png"); - lib->addNode("floodfillv2sampler", - "FF Sampler V2", + lib->addNode("floodfillv2sampler", "FF Sampler V2", ":nodes/floodfillsampler.png"); lib->addNode("curve", "Curve", ":nodes/curve.png"); @@ -234,30 +232,41 @@ Library* createLibraryV3() // ----------------------------------------------------------------------- // Phase 1 — Filters / Color // ----------------------------------------------------------------------- - lib->addNode ("edgedetect", "Edge Detect", ":nodes/bevel.png"); - lib->addNode ("highpass", "Highpass", ":nodes/blurv2.png"); - lib->addNode ("emboss", "Emboss", ":nodes/normalmap.png"); - lib->addNode ("vibrance", "Vibrance", ":nodes/hsl.png"); - lib->addNode ("colortomask", "Color To Mask", ":nodes/extractchannel.png"); - lib->addNode ("toongradient", "Toon Gradient", ":nodes/gradientmap.png"); - lib->addNode ("autolevels", "Auto Levels", ":nodes/histogramscan.png"); + lib->addNode("edgedetect", "Edge Detect", + ":nodes/bevel.png"); + lib->addNode("highpass", "Highpass", ":nodes/blurv2.png"); + lib->addNode("emboss", "Emboss", ":nodes/normalmap.png"); + lib->addNode("vibrance", "Vibrance", ":nodes/hsl.png"); + lib->addNode("colortomask", "Color To Mask", + ":nodes/extractchannel.png"); + lib->addNode("toongradient", "Toon Gradient", + ":nodes/gradientmap.png"); + lib->addNode("autolevels", "Auto Levels", + ":nodes/histogramscan.png"); // ----------------------------------------------------------------------- // Phase 2 — Generators // ----------------------------------------------------------------------- - lib->addNode("directionalscratches", "Directional Scratches", ":nodes/cell.png"); - lib->addNode ("roughgrain", "Rough Grain", ":nodes/cell.png"); - lib->addNode ("voronoifractal", "Voronoi Fractal", ":nodes/cell.png"); - lib->addNode ("truchet", "Truchet", ":nodes/hexagon.png"); - lib->addNode ("fbmdomainwarp", "FBM Domain Warp", ":nodes/fractalnoise.png"); + lib->addNode( + "directionalscratches", "Directional Scratches", ":nodes/cell.png"); + lib->addNode("roughgrain", "Rough Grain", + ":nodes/cell.png"); + lib->addNode("voronoifractal", "Voronoi Fractal", + ":nodes/cell.png"); + lib->addNode("truchet", "Truchet", ":nodes/hexagon.png"); + lib->addNode("fbmdomainwarp", "FBM Domain Warp", + ":nodes/fractalnoise.png"); // ----------------------------------------------------------------------- // Phase 3 — Multi-pass // ----------------------------------------------------------------------- - lib->addNode ("blurhq", "Blur HQ", ":nodes/blurv2.png"); - lib->addNode ("distancetransform", "Distance Transform", ":nodes/bevel.png"); - lib->addNode ("heightblend", "Height Blend", ":nodes/blend.png"); - lib->addNode ("makeittile", "Make It Tile", ":nodes/tile.png"); + lib->addNode("blurhq", "Blur HQ", ":nodes/blurv2.png"); + lib->addNode( + "distancetransform", "Distance Transform", ":nodes/bevel.png"); + lib->addNode("heightblend", "Height Blend", + ":nodes/blend.png"); + lib->addNode("makeittile", "Make It Tile", + ":nodes/tile.png"); return lib; } \ No newline at end of file From 64e1fb2e8b51e6271a5670eb8aedaa1cbc15cebe Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 24 May 2026 00:38:27 -0500 Subject: [PATCH 053/164] display bool prop widget --- .../widgets/properties/propertieswidget.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/texturelab/widgets/properties/propertieswidget.cpp b/src/texturelab/widgets/properties/propertieswidget.cpp index 355a9fa7..9a168eaf 100644 --- a/src/texturelab/widgets/properties/propertieswidget.cpp +++ b/src/texturelab/widgets/properties/propertieswidget.cpp @@ -65,6 +65,20 @@ void PropertiesWidget::setSelectedNode(const TextureNodePtr& node) }); layout->addWidget(widget); + } break; + case PropType::Bool: { + auto widget = new BoolPropWidget(); + widget->setProp((BoolProp*)prop); + propWidgets.append(widget); + + connect(widget, &BoolPropWidget::valueChanged, [=](bool value) { + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + + emit propertyUpdated(prop->name, value); + }); + layout->addWidget(widget); + } break; case PropType::Int: { auto widget = new IntPropWidget(); From 36161e4b3ad172a87793877db93e4387bd111282 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 24 May 2026 00:48:55 -0500 Subject: [PATCH 054/164] disable backface culling --- src/viewer3d/viewer3d.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/viewer3d/viewer3d.cpp b/src/viewer3d/viewer3d.cpp index cf1faf6f..4a2bdf0d 100644 --- a/src/viewer3d/viewer3d.cpp +++ b/src/viewer3d/viewer3d.cpp @@ -131,17 +131,15 @@ void Viewer3D::paintGL() gl->glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); gl->glEnable(GL_DEPTH_TEST); - // gl->glDisable(GL_CULL_FACE); + gl->glDisable(GL_CULL_FACE); vao->bind(); // Render skydome first (as background) if (skydomeMesh) { - gl->glDepthFunc(GL_LEQUAL); // Change depth function for skybox - gl->glDisable(GL_CULL_FACE); // Render from inside + gl->glDepthFunc(GL_LEQUAL); renderer->renderSkybox(skydomeMesh, viewMatrix, projMatrix); - gl->glEnable(GL_CULL_FACE); - gl->glDepthFunc(GL_LESS); // Reset depth function + gl->glDepthFunc(GL_LESS); } // render gltf mesh From e926dbb9898ae797cca87f7f0433de66c84a9f07 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 24 May 2026 00:49:05 -0500 Subject: [PATCH 055/164] optimize mesh generation --- src/viewer3d/geometry/cube.cpp | 233 ++++++++---------- src/viewer3d/geometry/cylinder.cpp | 370 ++++++++++++----------------- src/viewer3d/geometry/plane.cpp | 217 ++++++++--------- 3 files changed, 348 insertions(+), 472 deletions(-) diff --git a/src/viewer3d/geometry/cube.cpp b/src/viewer3d/geometry/cube.cpp index 0a924eab..ff3fef8e 100644 --- a/src/viewer3d/geometry/cube.cpp +++ b/src/viewer3d/geometry/cube.cpp @@ -3,186 +3,155 @@ #include #include #include -#include #include -#include +#include #define BUFFER_OFFSET(i) ((char*)NULL + (i)) Mesh* createCube(QOpenGLFunctions* gl, float width, float height, float depth, int widthSegments, int heightSegments, int depthSegments) { - widthSegments = std::max(1, widthSegments); + widthSegments = std::max(1, widthSegments); heightSegments = std::max(1, heightSegments); - depthSegments = std::max(1, depthSegments); + depthSegments = std::max(1, depthSegments); + + // Interleaved layout per vertex: pos(3) normal(3) uv(2) tangent(4) = 12 floats + static constexpr int kFloatsPerVertex = 12; + static constexpr int kStride = kFloatsPerVertex * sizeof(float); + + const int wS = widthSegments, hS = heightSegments, dS = depthSegments; + const int totalVertices = 2 * ((dS+1)*(hS+1) + (wS+1)*(dS+1) + (wS+1)*(hS+1)); + const int totalIndices = 12 * (dS*hS + wS*dS + wS*hS); + + std::vector interleaved; + std::vector indices; + interleaved.reserve(totalVertices * kFloatsPerVertex); + indices.reserve(totalIndices); + + int vertexOffset = 0; + + // u, v, w are axis indices (0=x,1=y,2=z) that map the face's local axes + // onto world space. udir/vdir flip the winding. depth is the face offset. + auto buildPlane = [&](int u, int v, int w, int udir, int vdir, + float faceW, float faceH, float faceD, + int gridX, int gridY) + { + const float segW = faceW / gridX; + const float segH = faceH / gridY; + const float halfW = faceW / 2.0f; + const float halfH = faceH / 2.0f; + const float halfD = faceD / 2.0f; + const int gridX1 = gridX + 1; + const int gridY1 = gridY + 1; + + // Normal and tangent are constant across the face — compute once. + float norm[3] = {}; + norm[w] = faceD > 0.0f ? 1.0f : -1.0f; + + float tang[3] = {}; + tang[u] = (float)udir; - // buffers - QVector indices; - QVector vertices; - QVector normals; - QVector tangents; - QVector uvs; - - int vertexCount = 0; - - auto buildPlane = [&](int u, int v, int w, int udir, int vdir, float width, - float height, float depth, int gridX, int gridY) { - float segmentWidth = width / gridX; - float segmentHeight = height / gridY; - - float widthHalf = width / 2.0f; - float heightHalf = height / 2.0f; - float depthHalf = depth / 2.0f; - - int gridX1 = gridX + 1; - int gridY1 = gridY + 1; - - int offset = vertexCount; - - QVector3D vec; - - // Generate vertices for (int iy = 0; iy < gridY1; iy++) { - float y = iy * segmentHeight - heightHalf; + const float y = iy * segH - halfH; for (int ix = 0; ix < gridX1; ix++) { - float x = ix * segmentWidth - widthHalf; - - // Set vertex position - vec[u] = x * udir; - vec[v] = y * vdir; - vec[w] = depthHalf; - - vertices.append(vec.x()); - vertices.append(vec.y()); - vertices.append(vec.z()); - - // Set normal - vec[u] = 0; - vec[v] = 0; - vec[w] = depth > 0 ? 1 : -1; - - normals.append(vec.x()); - normals.append(vec.y()); - normals.append(vec.z()); - - // Set tangent - QVector3D tangentVec; - tangentVec[u] = udir; - tangentVec[v] = 0; - tangentVec[w] = 0; - - tangents.append(tangentVec.x()); - tangents.append(tangentVec.y()); - tangents.append(tangentVec.z()); - tangents.append(1.0f); - - // Set UV - uvs.append(ix / (float)gridX); - uvs.append(1.0f - (iy / (float)gridY)); - - vertexCount++; + const float x = ix * segW - halfW; + + // position + float pos[3] = {}; + pos[u] = x * udir; + pos[v] = y * vdir; + pos[w] = halfD; + interleaved.push_back(pos[0]); + interleaved.push_back(pos[1]); + interleaved.push_back(pos[2]); + + // normal + interleaved.push_back(norm[0]); + interleaved.push_back(norm[1]); + interleaved.push_back(norm[2]); + + // uv + interleaved.push_back(ix / (float)gridX); + interleaved.push_back(1.0f - (iy / (float)gridY)); + + // tangent + interleaved.push_back(tang[0]); + interleaved.push_back(tang[1]); + interleaved.push_back(tang[2]); + interleaved.push_back(1.0f); } } - // Generate indices for (int iy = 0; iy < gridY; iy++) { for (int ix = 0; ix < gridX; ix++) { - unsigned int a = offset + ix + gridX1 * iy; - unsigned int b = offset + ix + gridX1 * (iy + 1); - unsigned int c = offset + (ix + 1) + gridX1 * (iy + 1); - unsigned int d = offset + (ix + 1) + gridX1 * iy; - - // Two triangles per quad - indices.append(a); - indices.append(b); - indices.append(d); - - indices.append(b); - indices.append(c); - indices.append(d); + const unsigned int a = vertexOffset + ix + gridX1 * iy; + const unsigned int b = vertexOffset + ix + gridX1 * (iy + 1); + const unsigned int c = vertexOffset + (ix + 1) + gridX1 * (iy + 1); + const unsigned int d = vertexOffset + (ix + 1) + gridX1 * iy; + + indices.push_back(a); + indices.push_back(b); + indices.push_back(d); + + indices.push_back(b); + indices.push_back(c); + indices.push_back(d); } } + + vertexOffset += gridX1 * gridY1; }; - // Build all 6 faces of the cube - buildPlane(2, 1, 0, -1, -1, depth, height, width, depthSegments, - heightSegments); // px - buildPlane(2, 1, 0, 1, -1, depth, height, -width, depthSegments, - heightSegments); // nx - buildPlane(0, 2, 1, 1, 1, width, depth, height, widthSegments, - depthSegments); // py - buildPlane(0, 2, 1, 1, -1, width, depth, -height, widthSegments, - depthSegments); // ny - buildPlane(0, 1, 2, 1, -1, width, height, depth, widthSegments, - heightSegments); // pz - buildPlane(0, 1, 2, -1, -1, width, height, -depth, widthSegments, - heightSegments); // nz - - // Build OpenGL buffers + buildPlane(2, 1, 0, -1, -1, depth, height, width, depthSegments, heightSegments); // px + buildPlane(2, 1, 0, 1, -1, depth, height, -width, depthSegments, heightSegments); // nx + buildPlane(0, 2, 1, 1, 1, width, depth, height, widthSegments, depthSegments); // py + buildPlane(0, 2, 1, 1, -1, width, depth, -height, widthSegments, depthSegments); // ny + buildPlane(0, 1, 2, 1, -1, width, height, depth, widthSegments, heightSegments); // pz + buildPlane(0, 1, 2, -1, -1, width, height, -depth, widthSegments, heightSegments); // nz + QOpenGLVertexArrayObject* vao = new QOpenGLVertexArrayObject(); vao->create(); vao->bind(); - QOpenGLBuffer* vbo; - - // Position buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); + auto vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); vbo->create(); vbo->bind(); vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(vertices.data(), vertices.length() * sizeof(float)); + vbo->allocate(interleaved.data(), (int)(interleaved.size() * sizeof(float))); + gl->glEnableVertexAttribArray((int)VertexUsage::Position); gl->glVertexAttribPointer((int)VertexUsage::Position, 3, GL_FLOAT, GL_FALSE, - 3 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(0)); - // Normal buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(normals.data(), normals.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::Normal); gl->glVertexAttribPointer((int)VertexUsage::Normal, 3, GL_FLOAT, GL_FALSE, - 3 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(3 * sizeof(float))); - // UV buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(uvs.data(), uvs.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::TexCoord0); - gl->glVertexAttribPointer((int)VertexUsage::TexCoord0, 2, GL_FLOAT, - GL_FALSE, 2 * sizeof(float), BUFFER_OFFSET(0)); + gl->glVertexAttribPointer((int)VertexUsage::TexCoord0, 2, GL_FLOAT, GL_FALSE, + kStride, BUFFER_OFFSET(6 * sizeof(float))); - // Tangent buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(tangents.data(), tangents.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::Tangent); gl->glVertexAttribPointer((int)VertexUsage::Tangent, 4, GL_FLOAT, GL_FALSE, - 4 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(8 * sizeof(float))); vao->release(); - // Index buffer auto ibo = new QOpenGLBuffer(QOpenGLBuffer::IndexBuffer); ibo->create(); ibo->bind(); ibo->setUsagePattern(QOpenGLBuffer::StaticDraw); - ibo->allocate(indices.data(), indices.length() * sizeof(unsigned int)); + ibo->allocate(indices.data(), (int)(indices.size() * sizeof(unsigned int))); auto mesh = new Mesh(); - mesh->vao = vao; - mesh->meshType = MeshType::Generated; - mesh->indexBuffer = ibo; - mesh->numElements = indices.count(); + mesh->vao = vao; + mesh->meshType = MeshType::Generated; + mesh->indexBuffer = ibo; + mesh->numElements = (int)indices.size(); mesh->indexByteOffset = 0; - mesh->indexType = GL_UNSIGNED_INT; - mesh->primitiveMode = GL_TRIANGLES; + mesh->indexType = GL_UNSIGNED_INT; + mesh->primitiveMode = GL_TRIANGLES; return mesh; } diff --git a/src/viewer3d/geometry/cylinder.cpp b/src/viewer3d/geometry/cylinder.cpp index 14afe4b8..447ae5a0 100644 --- a/src/viewer3d/geometry/cylinder.cpp +++ b/src/viewer3d/geometry/cylinder.cpp @@ -3,9 +3,9 @@ #include #include #include -#include #include -#include +#include +#include #define BUFFER_OFFSET(i) ((char*)NULL + (i)) @@ -16,272 +16,208 @@ Mesh* createCylinder(QOpenGLFunctions* gl, float radiusTop, float radiusBottom, radialSegments = std::max(3, radialSegments); heightSegments = std::max(1, heightSegments); - // buffers - QVector indices; - QVector vertices; - QVector normals; - QVector tangents; - QVector uvs; + // Interleaved layout per vertex: pos(3) normal(3) uv(2) tangent(4) = 12 floats + static constexpr int kFloatsPerVertex = 12; + static constexpr int kStride = kFloatsPerVertex * sizeof(float); + + const int colCount = radialSegments + 1; + + // Exact vertex / index counts for upfront reservation. + const bool hasCaps = !openEnded; + const bool hasTop = hasCaps && radiusTop > 0.0f; + const bool hasBot = hasCaps && radiusBottom > 0.0f; + const int capVerts = (hasTop ? colCount + 1 : 0) + (hasBot ? colCount + 1 : 0); + const int capIndices = (hasTop ? radialSegments : 0) + (hasBot ? radialSegments : 0); + + const int totalVertices = (heightSegments + 1) * colCount + capVerts; + const int totalIndices = heightSegments * radialSegments * 6 + capIndices * 3; + + std::vector interleaved; + std::vector indices; + interleaved.reserve(totalVertices * kFloatsPerVertex); + indices.reserve(totalIndices); + + // Precompute sin/cos for each column — reused by torso and both caps. + std::vector sinT(colCount), cosT(colCount); + for (int x = 0; x < colCount; x++) { + const float theta = (x / (float)radialSegments) * (float)(M_PI * 2.0); + sinT[x] = std::sin(theta); + cosT[x] = std::cos(theta); + } + + // Slope is constant for the whole cylinder/cone. + const float slope = std::atan2(radiusBottom - radiusTop, height); + const float cosSlope = std::cos(slope); + const float sinSlope = std::sin(slope); - int index = 0; - QVector> indexArray; + const float halfHeight = height / 2.0f; - float halfHeight = height / 2.0f; + // ------------------------------------------------------------------------- + // Torso + // ------------------------------------------------------------------------- + const int torsoVertexBase = 0; - // Generate torso for (int y = 0; y <= heightSegments; y++) { - QVector indexRow; - - float v = y / (float)heightSegments; - float radius = v * (radiusBottom - radiusTop) + radiusTop; - - for (int x = 0; x <= radialSegments; x++) { - float u = x / (float)radialSegments; - float theta = u * M_PI * 2.0f; - - float sinTheta = std::sin(theta); - float cosTheta = std::cos(theta); - - // Vertex position - float vx = radius * sinTheta; - float vy = -v * height + halfHeight; - float vz = radius * cosTheta; - - vertices.append(vx); - vertices.append(vy); - vertices.append(vz); - - // Normal (accounting for cone slope) - float slope = std::atan2(radiusBottom - radiusTop, height); - QVector3D normal(sinTheta * std::cos(slope), std::sin(slope), - cosTheta * std::cos(slope)); - normal.normalize(); - normals.append(normal.x()); - normals.append(normal.y()); - normals.append(normal.z()); - - // Tangent (perpendicular to normal, going around the cylinder) - QVector3D tangent(cosTheta, 0.0f, -sinTheta); - tangent.normalize(); - tangents.append(tangent.x()); - tangents.append(tangent.y()); - tangents.append(tangent.z()); - tangents.append(1.0f); - - // UV - uvs.append(u * 2.0f); - uvs.append(1.0f - v); - - indexRow.append(index++); + const float v = y / (float)heightSegments; + const float radius = v * (radiusBottom - radiusTop) + radiusTop; + const float vy = -v * height + halfHeight; + + for (int x = 0; x < colCount; x++) { + const float s = sinT[x], c = cosT[x]; + + // position + interleaved.push_back(radius * s); + interleaved.push_back(vy); + interleaved.push_back(radius * c); + + // normal — (sinTheta*cosSlope, sinSlope, cosTheta*cosSlope) is already unit length + interleaved.push_back(s * cosSlope); + interleaved.push_back(sinSlope); + interleaved.push_back(c * cosSlope); + + // uv + interleaved.push_back((x / (float)radialSegments) * 2.0f); + interleaved.push_back(1.0f - v); + + // tangent — (cosTheta, 0, -sinTheta) is already unit length + interleaved.push_back(c); + interleaved.push_back(0.0f); + interleaved.push_back(-s); + interleaved.push_back(1.0f); } - - indexArray.append(indexRow); } - // Generate indices for torso for (int y = 0; y < heightSegments; y++) { for (int x = 0; x < radialSegments; x++) { - unsigned int a = indexArray[y][x]; - unsigned int b = indexArray[y + 1][x]; - unsigned int c = indexArray[y + 1][x + 1]; - unsigned int d = indexArray[y][x + 1]; - - indices.append(a); - indices.append(b); - indices.append(d); - - indices.append(b); - indices.append(c); - indices.append(d); + const unsigned int a = torsoVertexBase + y * colCount + x; + const unsigned int b = torsoVertexBase + (y + 1) * colCount + x; + const unsigned int c = torsoVertexBase + (y + 1) * colCount + x + 1; + const unsigned int d = torsoVertexBase + y * colCount + x + 1; + + indices.push_back(a); + indices.push_back(b); + indices.push_back(d); + + indices.push_back(b); + indices.push_back(c); + indices.push_back(d); } } - // Generate top cap - if (!openEnded && radiusTop > 0) { - unsigned int centerIndex = index; - - // Center vertex - vertices.append(0.0f); - vertices.append(halfHeight); - vertices.append(0.0f); - - normals.append(0.0f); - normals.append(-1.0f); - normals.append(0.0f); - - tangents.append(1.0f); - tangents.append(0.0f); - tangents.append(0.0f); - tangents.append(1.0f); - - uvs.append(0.5f); - uvs.append(0.5f); - - index++; - - // Ring vertices - for (int x = 0; x <= radialSegments; x++) { - float u = x / (float)radialSegments; - float theta = u * M_PI * 2.0f; - - float sinTheta = std::sin(theta); - float cosTheta = std::cos(theta); - - vertices.append(radiusTop * sinTheta); - vertices.append(halfHeight); - vertices.append(radiusTop * cosTheta); - - normals.append(0.0f); - normals.append(-1.0f); - normals.append(0.0f); - - tangents.append(1.0f); - tangents.append(0.0f); - tangents.append(0.0f); - tangents.append(1.0f); - - uvs.append((cosTheta * 0.5f) + 0.5f); - uvs.append((sinTheta * 0.5f) + 0.5f); - - index++; + int nextVertex = (heightSegments + 1) * colCount; + + // ------------------------------------------------------------------------- + // Top cap + // ------------------------------------------------------------------------- + if (hasTop) { + const unsigned int centerIndex = nextVertex++; + + // center + interleaved.push_back(0.0f); + interleaved.push_back(halfHeight); + interleaved.push_back(0.0f); + interleaved.push_back(0.0f); interleaved.push_back(-1.0f); interleaved.push_back(0.0f); // normal + interleaved.push_back(0.5f); interleaved.push_back(0.5f); // uv + interleaved.push_back(1.0f); interleaved.push_back(0.0f); interleaved.push_back(0.0f); interleaved.push_back(1.0f); // tangent + + for (int x = 0; x < colCount; x++) { + const float s = sinT[x], c = cosT[x]; + + interleaved.push_back(radiusTop * s); + interleaved.push_back(halfHeight); + interleaved.push_back(radiusTop * c); + interleaved.push_back(0.0f); interleaved.push_back(-1.0f); interleaved.push_back(0.0f); + interleaved.push_back((c * 0.5f) + 0.5f); + interleaved.push_back((s * 0.5f) + 0.5f); + interleaved.push_back(1.0f); interleaved.push_back(0.0f); interleaved.push_back(0.0f); interleaved.push_back(1.0f); + + nextVertex++; } - // Generate top cap indices for (int x = 0; x < radialSegments; x++) { - unsigned int c = centerIndex + x + 1; - unsigned int d = centerIndex + x + 2; - - indices.append(d); - indices.append(c); - indices.append(centerIndex); + indices.push_back(centerIndex + x + 2); + indices.push_back(centerIndex + x + 1); + indices.push_back(centerIndex); } } - // Generate bottom cap - if (!openEnded && radiusBottom > 0) { - unsigned int centerIndex = index; - - // Center vertex - vertices.append(0.0f); - vertices.append(-halfHeight); - vertices.append(0.0f); - - normals.append(0.0f); - normals.append(1.0f); - normals.append(0.0f); - - tangents.append(1.0f); - tangents.append(0.0f); - tangents.append(0.0f); - tangents.append(1.0f); - - uvs.append(0.5f); - uvs.append(0.5f); - - index++; - - // Ring vertices - for (int x = 0; x <= radialSegments; x++) { - float u = x / (float)radialSegments; - float theta = u * M_PI * 2.0f; - - float sinTheta = std::sin(theta); - float cosTheta = std::cos(theta); - - vertices.append(radiusBottom * sinTheta); - vertices.append(-halfHeight); - vertices.append(radiusBottom * cosTheta); - - normals.append(0.0f); - normals.append(1.0f); - normals.append(0.0f); - - tangents.append(1.0f); - tangents.append(0.0f); - tangents.append(0.0f); - tangents.append(1.0f); - - uvs.append((cosTheta * 0.5f) + 0.5f); - uvs.append((sinTheta * 0.5f) + 0.5f); - - index++; + // ------------------------------------------------------------------------- + // Bottom cap + // ------------------------------------------------------------------------- + if (hasBot) { + const unsigned int centerIndex = nextVertex++; + + // center + interleaved.push_back(0.0f); + interleaved.push_back(-halfHeight); + interleaved.push_back(0.0f); + interleaved.push_back(0.0f); interleaved.push_back(1.0f); interleaved.push_back(0.0f); // normal + interleaved.push_back(0.5f); interleaved.push_back(0.5f); // uv + interleaved.push_back(1.0f); interleaved.push_back(0.0f); interleaved.push_back(0.0f); interleaved.push_back(1.0f); // tangent + + for (int x = 0; x < colCount; x++) { + const float s = sinT[x], c = cosT[x]; + + interleaved.push_back(radiusBottom * s); + interleaved.push_back(-halfHeight); + interleaved.push_back(radiusBottom * c); + interleaved.push_back(0.0f); interleaved.push_back(1.0f); interleaved.push_back(0.0f); + interleaved.push_back((c * 0.5f) + 0.5f); + interleaved.push_back((s * 0.5f) + 0.5f); + interleaved.push_back(1.0f); interleaved.push_back(0.0f); interleaved.push_back(0.0f); interleaved.push_back(1.0f); + + nextVertex++; } - // Generate bottom cap indices for (int x = 0; x < radialSegments; x++) { - unsigned int c = centerIndex + x + 1; - unsigned int d = centerIndex + x + 2; - - indices.append(centerIndex); - indices.append(c); - indices.append(d); + indices.push_back(centerIndex); + indices.push_back(centerIndex + x + 1); + indices.push_back(centerIndex + x + 2); } } - // Build OpenGL buffers QOpenGLVertexArrayObject* vao = new QOpenGLVertexArrayObject(); vao->create(); vao->bind(); - QOpenGLBuffer* vbo; - - // Position buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); + auto vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); vbo->create(); vbo->bind(); vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(vertices.data(), vertices.length() * sizeof(float)); + vbo->allocate(interleaved.data(), (int)(interleaved.size() * sizeof(float))); + gl->glEnableVertexAttribArray((int)VertexUsage::Position); gl->glVertexAttribPointer((int)VertexUsage::Position, 3, GL_FLOAT, GL_FALSE, - 3 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(0)); - // Normal buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(normals.data(), normals.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::Normal); gl->glVertexAttribPointer((int)VertexUsage::Normal, 3, GL_FLOAT, GL_FALSE, - 3 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(3 * sizeof(float))); - // UV buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(uvs.data(), uvs.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::TexCoord0); - gl->glVertexAttribPointer((int)VertexUsage::TexCoord0, 2, GL_FLOAT, - GL_FALSE, 2 * sizeof(float), BUFFER_OFFSET(0)); + gl->glVertexAttribPointer((int)VertexUsage::TexCoord0, 2, GL_FLOAT, GL_FALSE, + kStride, BUFFER_OFFSET(6 * sizeof(float))); - // Tangent buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(tangents.data(), tangents.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::Tangent); gl->glVertexAttribPointer((int)VertexUsage::Tangent, 4, GL_FLOAT, GL_FALSE, - 4 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(8 * sizeof(float))); vao->release(); - // Index buffer auto ibo = new QOpenGLBuffer(QOpenGLBuffer::IndexBuffer); ibo->create(); ibo->bind(); ibo->setUsagePattern(QOpenGLBuffer::StaticDraw); - ibo->allocate(indices.data(), indices.length() * sizeof(unsigned int)); + ibo->allocate(indices.data(), (int)(indices.size() * sizeof(unsigned int))); auto mesh = new Mesh(); - mesh->vao = vao; - mesh->meshType = MeshType::Generated; - mesh->indexBuffer = ibo; - mesh->numElements = indices.count(); + mesh->vao = vao; + mesh->meshType = MeshType::Generated; + mesh->indexBuffer = ibo; + mesh->numElements = (int)indices.size(); mesh->indexByteOffset = 0; - mesh->indexType = GL_UNSIGNED_INT; - mesh->primitiveMode = GL_TRIANGLES; + mesh->indexType = GL_UNSIGNED_INT; + mesh->primitiveMode = GL_TRIANGLES; return mesh; } diff --git a/src/viewer3d/geometry/plane.cpp b/src/viewer3d/geometry/plane.cpp index 96eee2fe..b254592d 100644 --- a/src/viewer3d/geometry/plane.cpp +++ b/src/viewer3d/geometry/plane.cpp @@ -4,7 +4,7 @@ #include #include #include -#include +#include #define BUFFER_OFFSET(i) ((char*)NULL + (i)) @@ -12,175 +12,146 @@ Mesh* createPlane(QOpenGLFunctions* gl, float width, float height, int widthSegments, int heightSegments, PlaneOrientation orientation) { - widthSegments = std::max(1, widthSegments); + widthSegments = std::max(1, widthSegments); heightSegments = std::max(1, heightSegments); - float width_half = width / 2.0f; - float height_half = height / 2.0f; + const float width_half = width / 2.0f; + const float height_half = height / 2.0f; - int gridX = widthSegments; - int gridY = heightSegments; + const int gridX1 = widthSegments + 1; + const int gridY1 = heightSegments + 1; - int gridX1 = gridX + 1; - int gridY1 = gridY + 1; + const float segment_width = width / widthSegments; + const float segment_height = height / heightSegments; - float segment_width = width / gridX; - float segment_height = height / gridY; + const int vertexCount = gridX1 * gridY1; - // buffers - QVector indices; - QVector vertices; - QVector normals; - QVector tangents; - QVector uvs; + // Interleaved layout per vertex: pos(3) normal(3) uv(2) tangent(4) = 12 floats + static constexpr int kFloatsPerVertex = 12; + static constexpr int kStride = kFloatsPerVertex * sizeof(float); + + std::vector interleaved; + interleaved.reserve(vertexCount * kFloatsPerVertex); + + std::vector indices; + indices.reserve(widthSegments * heightSegments * 6); + + // Precompute orientation-dependent constants to keep the inner loop branch-free. + float nx, ny, nz; + float tx, ty, tz; + const bool flipV = (orientation != PlaneOrientation::XZ); + + if (orientation == PlaneOrientation::XY) { + nx = 0.0f; ny = 0.0f; nz = -1.0f; + tx = 1.0f; ty = 0.0f; tz = 0.0f; + } else if (orientation == PlaneOrientation::YZ) { + nx = -1.0f; ny = 0.0f; nz = 0.0f; + tx = 0.0f; ty = 0.0f; tz = 1.0f; + } else { // XZ + nx = 0.0f; ny = 1.0f; nz = 0.0f; + tx = 1.0f; ty = 0.0f; tz = 0.0f; + } - // Generate vertices, normals, uvs for (int iy = 0; iy < gridY1; iy++) { - float v = iy * segment_height - height_half; + const float v = iy * segment_height - height_half; for (int ix = 0; ix < gridX1; ix++) { - float u = ix * segment_width - width_half; + const float u = ix * segment_width - width_half; + // position if (orientation == PlaneOrientation::XY) { - // XY plane, normal facing -Z - vertices.append(u); - vertices.append(-v); - vertices.append(0.0f); - - normals.append(0.0f); - normals.append(0.0f); - normals.append(-1.0f); - - tangents.append(1.0f); - tangents.append(0.0f); - tangents.append(0.0f); - tangents.append(1.0f); - } - else if (orientation == PlaneOrientation::YZ) { - // YZ plane, normal facing -X - vertices.append(0.0f); - vertices.append(-v); - vertices.append(u); - - normals.append(-1.0f); - normals.append(0.0f); - normals.append(0.0f); - - tangents.append(0.0f); - tangents.append(0.0f); - tangents.append(1.0f); - tangents.append(1.0f); - } - else { // PlaneOrientation::XZ - // XZ plane, normal facing +Y - vertices.append(u); - vertices.append(0.0f); - vertices.append(v); - - normals.append(0.0f); - normals.append(1.0f); - normals.append(0.0f); - - tangents.append(1.0f); - tangents.append(0.0f); - tangents.append(0.0f); - tangents.append(1.0f); + interleaved.push_back(u); + interleaved.push_back(-v); + interleaved.push_back(0.0f); + } else if (orientation == PlaneOrientation::YZ) { + interleaved.push_back(0.0f); + interleaved.push_back(-v); + interleaved.push_back(u); + } else { + interleaved.push_back(u); + interleaved.push_back(0.0f); + interleaved.push_back(v); } - // UV coordinates - uvs.append(ix / (float)gridX); - if (orientation == PlaneOrientation::XZ) { - uvs.append(iy / (float)gridY); - } - else { - uvs.append(1.0f - (iy / (float)gridY)); - } + // normal + interleaved.push_back(nx); + interleaved.push_back(ny); + interleaved.push_back(nz); + + // uv + const float uvx = ix / (float)widthSegments; + const float uvy = flipV ? 1.0f - (iy / (float)heightSegments) + : iy / (float)heightSegments; + interleaved.push_back(uvx); + interleaved.push_back(uvy); + + // tangent + interleaved.push_back(tx); + interleaved.push_back(ty); + interleaved.push_back(tz); + interleaved.push_back(1.0f); } } - // Generate indices - for (int iy = 0; iy < gridY; iy++) { - for (int ix = 0; ix < gridX; ix++) { - unsigned int a = ix + gridX1 * iy; - unsigned int b = ix + gridX1 * (iy + 1); - unsigned int c = (ix + 1) + gridX1 * (iy + 1); - unsigned int d = (ix + 1) + gridX1 * iy; - - // Two triangles per quad - indices.append(a); - indices.append(b); - indices.append(d); - - indices.append(b); - indices.append(c); - indices.append(d); + for (int iy = 0; iy < heightSegments; iy++) { + for (int ix = 0; ix < widthSegments; ix++) { + const unsigned int a = ix + gridX1 * iy; + const unsigned int b = ix + gridX1 * (iy + 1); + const unsigned int c = (ix + 1) + gridX1 * (iy + 1); + const unsigned int d = (ix + 1) + gridX1 * iy; + + indices.push_back(a); + indices.push_back(b); + indices.push_back(d); + + indices.push_back(b); + indices.push_back(c); + indices.push_back(d); } } - // Build OpenGL buffers QOpenGLVertexArrayObject* vao = new QOpenGLVertexArrayObject(); vao->create(); vao->bind(); - QOpenGLBuffer* vbo; - - // Position buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); + auto vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); vbo->create(); vbo->bind(); vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(vertices.data(), vertices.length() * sizeof(float)); + vbo->allocate(interleaved.data(), (int)(interleaved.size() * sizeof(float))); + gl->glEnableVertexAttribArray((int)VertexUsage::Position); gl->glVertexAttribPointer((int)VertexUsage::Position, 3, GL_FLOAT, GL_FALSE, - 3 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(0)); - // Normal buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(normals.data(), normals.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::Normal); gl->glVertexAttribPointer((int)VertexUsage::Normal, 3, GL_FLOAT, GL_FALSE, - 3 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(3 * sizeof(float))); - // UV buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(uvs.data(), uvs.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::TexCoord0); - gl->glVertexAttribPointer((int)VertexUsage::TexCoord0, 2, GL_FLOAT, - GL_FALSE, 2 * sizeof(float), BUFFER_OFFSET(0)); + gl->glVertexAttribPointer((int)VertexUsage::TexCoord0, 2, GL_FLOAT, GL_FALSE, + kStride, BUFFER_OFFSET(6 * sizeof(float))); - // Tangent buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(tangents.data(), tangents.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::Tangent); gl->glVertexAttribPointer((int)VertexUsage::Tangent, 4, GL_FLOAT, GL_FALSE, - 4 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(8 * sizeof(float))); vao->release(); - // Index buffer auto ibo = new QOpenGLBuffer(QOpenGLBuffer::IndexBuffer); ibo->create(); ibo->bind(); ibo->setUsagePattern(QOpenGLBuffer::StaticDraw); - ibo->allocate(indices.data(), indices.length() * sizeof(unsigned int)); + ibo->allocate(indices.data(), (int)(indices.size() * sizeof(unsigned int))); auto mesh = new Mesh(); - mesh->vao = vao; - mesh->meshType = MeshType::Generated; - mesh->indexBuffer = ibo; - mesh->numElements = indices.count(); + mesh->vao = vao; + mesh->meshType = MeshType::Generated; + mesh->indexBuffer = ibo; + mesh->numElements = (int)indices.size(); mesh->indexByteOffset = 0; - mesh->indexType = GL_UNSIGNED_INT; - mesh->primitiveMode = GL_TRIANGLES; + mesh->indexType = GL_UNSIGNED_INT; + mesh->primitiveMode = GL_TRIANGLES; return mesh; } From bcb7cc4bce8ad78d193b4e088dff6054e7af3156 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 24 May 2026 01:09:38 -0500 Subject: [PATCH 056/164] add rounded ends to cylinder --- src/viewer3d/geometry/cylinder.cpp | 181 +++++++++++------------------ src/viewer3d/geometry/geometry.h | 3 +- src/viewer3d/viewer3d.cpp | 2 +- 3 files changed, 68 insertions(+), 118 deletions(-) diff --git a/src/viewer3d/geometry/cylinder.cpp b/src/viewer3d/geometry/cylinder.cpp index 447ae5a0..1f4adac5 100644 --- a/src/viewer3d/geometry/cylinder.cpp +++ b/src/viewer3d/geometry/cylinder.cpp @@ -11,33 +11,34 @@ Mesh* createCylinder(QOpenGLFunctions* gl, float radiusTop, float radiusBottom, float height, int radialSegments, int heightSegments, - bool openEnded) + float bevelRadius, int bevelSegments, + float uvScaleU, float uvScaleV) { radialSegments = std::max(3, radialSegments); heightSegments = std::max(1, heightSegments); + bevelSegments = std::max(1, bevelSegments); + bevelRadius = std::min({bevelRadius, radiusTop, radiusBottom, height / 2.0f}); // Interleaved layout per vertex: pos(3) normal(3) uv(2) tangent(4) = 12 floats static constexpr int kFloatsPerVertex = 12; static constexpr int kStride = kFloatsPerVertex * sizeof(float); - const int colCount = radialSegments + 1; - - // Exact vertex / index counts for upfront reservation. - const bool hasCaps = !openEnded; - const bool hasTop = hasCaps && radiusTop > 0.0f; - const bool hasBot = hasCaps && radiusBottom > 0.0f; - const int capVerts = (hasTop ? colCount + 1 : 0) + (hasBot ? colCount + 1 : 0); - const int capIndices = (hasTop ? radialSegments : 0) + (hasBot ? radialSegments : 0); - - const int totalVertices = (heightSegments + 1) * colCount + capVerts; - const int totalIndices = heightSegments * radialSegments * 6 + capIndices * 3; + // Ring layout top→bottom: + // top bevel: bevelSegments+1 rings (0 .. bevelSegments) + // torso: heightSegments rings (bevelSegments+1 .. bevelSegments+heightSegments) + // bottom bevel: bevelSegments rings (bevelSegments+heightSegments+1 .. 2*bevelSegments+heightSegments) + // Junction rings are owned by the preceding section; the next section skips ring 0. + const int totalRings = 2 * bevelSegments + heightSegments + 1; + const int colCount = radialSegments + 1; + const int totalVertices = totalRings * colCount; + const int totalIndices = (totalRings - 1) * radialSegments * 6; std::vector interleaved; std::vector indices; interleaved.reserve(totalVertices * kFloatsPerVertex); indices.reserve(totalIndices); - // Precompute sin/cos for each column — reused by torso and both caps. + // Precompute sin/cos per column — reused by all sections. std::vector sinT(colCount), cosT(colCount); for (int x = 0; x < colCount; x++) { const float theta = (x / (float)radialSegments) * (float)(M_PI * 2.0); @@ -45,54 +46,74 @@ Mesh* createCylinder(QOpenGLFunctions* gl, float radiusTop, float radiusBottom, cosT[x] = std::cos(theta); } - // Slope is constant for the whole cylinder/cone. - const float slope = std::atan2(radiusBottom - radiusTop, height); - const float cosSlope = std::cos(slope); - const float sinSlope = std::sin(slope); - const float halfHeight = height / 2.0f; - // ------------------------------------------------------------------------- - // Torso - // ------------------------------------------------------------------------- - const int torsoVertexBase = 0; - - for (int y = 0; y <= heightSegments; y++) { - const float v = y / (float)heightSegments; - const float radius = v * (radiusBottom - radiusTop) + radiusTop; - const float vy = -v * height + halfHeight; + // Slope for the torso normal (constant; 0 for a true cylinder). + const float slope = std::atan2(radiusBottom - radiusTop, height); + const float cosSlope = std::cos(slope); + const float sinSlope = std::sin(slope); + // Push one full ring. normR = outward radial scale, normY = vertical normal component. + int ringIndex = 0; + auto pushRing = [&](float r, float y, float normR, float normY) { + const float globalV = ringIndex / (float)(totalRings - 1); for (int x = 0; x < colCount; x++) { const float s = sinT[x], c = cosT[x]; - // position - interleaved.push_back(radius * s); - interleaved.push_back(vy); - interleaved.push_back(radius * c); + interleaved.push_back(r * s); + interleaved.push_back(y); + interleaved.push_back(r * c); - // normal — (sinTheta*cosSlope, sinSlope, cosTheta*cosSlope) is already unit length - interleaved.push_back(s * cosSlope); - interleaved.push_back(sinSlope); - interleaved.push_back(c * cosSlope); + interleaved.push_back(normR * s); + interleaved.push_back(normY); + interleaved.push_back(normR * c); - // uv - interleaved.push_back((x / (float)radialSegments) * 2.0f); - interleaved.push_back(1.0f - v); + interleaved.push_back((x / (float)radialSegments) * uvScaleU); + interleaved.push_back((1.0f - globalV) * uvScaleV); - // tangent — (cosTheta, 0, -sinTheta) is already unit length interleaved.push_back(c); interleaved.push_back(0.0f); interleaved.push_back(-s); interleaved.push_back(1.0f); } + ringIndex++; + }; + + // ---- Top bevel (rings 0..bevelSegments) ---- + // phi=PI/2 → top rim; phi=0 → torso junction. + for (int i = 0; i <= bevelSegments; i++) { + const float phi = (float)(bevelSegments - i) / bevelSegments * (float)(M_PI / 2.0); + const float r = (radiusTop - bevelRadius) + bevelRadius * std::cos(phi); + const float y = (halfHeight - bevelRadius) + bevelRadius * std::sin(phi); + pushRing(r, y, std::cos(phi), std::sin(phi)); } - for (int y = 0; y < heightSegments; y++) { + // ---- Torso (rings bevelSegments+1..bevelSegments+heightSegments) ---- + // Skip j=0 — that junction ring was already pushed by the top bevel. + for (int j = 1; j <= heightSegments; j++) { + const float v = j / (float)heightSegments; + const float r = v * (radiusBottom - radiusTop) + radiusTop; + const float y = (halfHeight - bevelRadius) - v * (height - 2.0f * bevelRadius); + pushRing(r, y, cosSlope, sinSlope); + } + + // ---- Bottom bevel (rings bevelSegments+heightSegments+1..2*bevelSegments+heightSegments) ---- + // Skip k=0 — that junction ring was already pushed by the torso. + // phi=0 → torso junction; phi=PI/2 → bottom rim. + for (int k = 1; k <= bevelSegments; k++) { + const float phi = (float)k / bevelSegments * (float)(M_PI / 2.0); + const float r = (radiusBottom - bevelRadius) + bevelRadius * std::cos(phi); + const float y = -(halfHeight - bevelRadius) - bevelRadius * std::sin(phi); + pushRing(r, y, std::cos(phi), -std::sin(phi)); + } + + // ---- Indices: connect every adjacent pair of rings ---- + for (int r = 0; r < totalRings - 1; r++) { for (int x = 0; x < radialSegments; x++) { - const unsigned int a = torsoVertexBase + y * colCount + x; - const unsigned int b = torsoVertexBase + (y + 1) * colCount + x; - const unsigned int c = torsoVertexBase + (y + 1) * colCount + x + 1; - const unsigned int d = torsoVertexBase + y * colCount + x + 1; + const unsigned int a = r * colCount + x; + const unsigned int b = (r + 1) * colCount + x; + const unsigned int c = (r + 1) * colCount + x + 1; + const unsigned int d = r * colCount + x + 1; indices.push_back(a); indices.push_back(b); @@ -104,78 +125,6 @@ Mesh* createCylinder(QOpenGLFunctions* gl, float radiusTop, float radiusBottom, } } - int nextVertex = (heightSegments + 1) * colCount; - - // ------------------------------------------------------------------------- - // Top cap - // ------------------------------------------------------------------------- - if (hasTop) { - const unsigned int centerIndex = nextVertex++; - - // center - interleaved.push_back(0.0f); - interleaved.push_back(halfHeight); - interleaved.push_back(0.0f); - interleaved.push_back(0.0f); interleaved.push_back(-1.0f); interleaved.push_back(0.0f); // normal - interleaved.push_back(0.5f); interleaved.push_back(0.5f); // uv - interleaved.push_back(1.0f); interleaved.push_back(0.0f); interleaved.push_back(0.0f); interleaved.push_back(1.0f); // tangent - - for (int x = 0; x < colCount; x++) { - const float s = sinT[x], c = cosT[x]; - - interleaved.push_back(radiusTop * s); - interleaved.push_back(halfHeight); - interleaved.push_back(radiusTop * c); - interleaved.push_back(0.0f); interleaved.push_back(-1.0f); interleaved.push_back(0.0f); - interleaved.push_back((c * 0.5f) + 0.5f); - interleaved.push_back((s * 0.5f) + 0.5f); - interleaved.push_back(1.0f); interleaved.push_back(0.0f); interleaved.push_back(0.0f); interleaved.push_back(1.0f); - - nextVertex++; - } - - for (int x = 0; x < radialSegments; x++) { - indices.push_back(centerIndex + x + 2); - indices.push_back(centerIndex + x + 1); - indices.push_back(centerIndex); - } - } - - // ------------------------------------------------------------------------- - // Bottom cap - // ------------------------------------------------------------------------- - if (hasBot) { - const unsigned int centerIndex = nextVertex++; - - // center - interleaved.push_back(0.0f); - interleaved.push_back(-halfHeight); - interleaved.push_back(0.0f); - interleaved.push_back(0.0f); interleaved.push_back(1.0f); interleaved.push_back(0.0f); // normal - interleaved.push_back(0.5f); interleaved.push_back(0.5f); // uv - interleaved.push_back(1.0f); interleaved.push_back(0.0f); interleaved.push_back(0.0f); interleaved.push_back(1.0f); // tangent - - for (int x = 0; x < colCount; x++) { - const float s = sinT[x], c = cosT[x]; - - interleaved.push_back(radiusBottom * s); - interleaved.push_back(-halfHeight); - interleaved.push_back(radiusBottom * c); - interleaved.push_back(0.0f); interleaved.push_back(1.0f); interleaved.push_back(0.0f); - interleaved.push_back((c * 0.5f) + 0.5f); - interleaved.push_back((s * 0.5f) + 0.5f); - interleaved.push_back(1.0f); interleaved.push_back(0.0f); interleaved.push_back(0.0f); interleaved.push_back(1.0f); - - nextVertex++; - } - - for (int x = 0; x < radialSegments; x++) { - indices.push_back(centerIndex); - indices.push_back(centerIndex + x + 1); - indices.push_back(centerIndex + x + 2); - } - } - QOpenGLVertexArrayObject* vao = new QOpenGLVertexArrayObject(); vao->create(); vao->bind(); diff --git a/src/viewer3d/geometry/geometry.h b/src/viewer3d/geometry/geometry.h index 97c85985..6abc3296 100644 --- a/src/viewer3d/geometry/geometry.h +++ b/src/viewer3d/geometry/geometry.h @@ -27,7 +27,8 @@ Mesh* createPlane(QOpenGLFunctions* gl, float width = 1, float height = 1, Mesh* createCylinder(QOpenGLFunctions* gl, float radiusTop = 1, float radiusBottom = 1, float height = 1, int radialSegments = 32, int heightSegments = 1, - bool openEnded = false); + float bevelRadius = 0.1f, int bevelSegments = 4, + float uvScaleU = 3.0f, float uvScaleV = 1.0f); // Create a subdivided cube mesh with normals, tangents, and UVs // https://github.com/mrdoob/three.js/blob/master/src/geometries/BoxGeometry.js diff --git a/src/viewer3d/viewer3d.cpp b/src/viewer3d/viewer3d.cpp index 4a2bdf0d..79d9097c 100644 --- a/src/viewer3d/viewer3d.cpp +++ b/src/viewer3d/viewer3d.cpp @@ -608,7 +608,7 @@ void Viewer3D::setModel(const QString& modelType) } else if (modelType == "cylinder") { // Create a cylinder with height subdivisions for displacement mapping - gltfMesh = createCylinder(this->gl, 1, 1, 2, 1000, 1000, false); + gltfMesh = createCylinder(this->gl, 1, 1, 2, 1000, 1000, 0.1f, 16); } else if (modelType == "cube") { // Create a subdivided cube From 50b1ef799c826a7a4422563376522b4098a41feb Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 24 May 2026 23:12:48 -0500 Subject: [PATCH 057/164] add spread node --- src/texturelab/CMakeLists.txt | 1 + src/texturelab/libraries/library.cpp | 1 + src/texturelab/libraries/libv3.h | 7 + src/texturelab/libraries/v3/spread.cpp | 220 +++++++++++++++++++++++++ 4 files changed, 229 insertions(+) create mode 100644 src/texturelab/libraries/v3/spread.cpp diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 94ef526d..e64d2206 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -130,6 +130,7 @@ set(LIBRARYV3 # Phase 3 — Multi-pass ./libraries/v3/blurhq.cpp ./libraries/v3/distancetransform.cpp + ./libraries/v3/spread.cpp ./libraries/v3/heightblend.cpp ./libraries/v3/makeittile.cpp ) diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index 983e2f4b..5d3bb4d0 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -263,6 +263,7 @@ Library* createLibraryV3() lib->addNode("blurhq", "Blur HQ", ":nodes/blurv2.png"); lib->addNode( "distancetransform", "Distance Transform", ":nodes/bevel.png"); + lib->addNode("spread", "Spread", ":nodes/bevel.png"); lib->addNode("heightblend", "Height Blend", ":nodes/blend.png"); lib->addNode("makeittile", "Make It Tile", diff --git a/src/texturelab/libraries/libv3.h b/src/texturelab/libraries/libv3.h index 856276f0..86f97e2e 100644 --- a/src/texturelab/libraries/libv3.h +++ b/src/texturelab/libraries/libv3.h @@ -92,6 +92,13 @@ class DistanceTransformNode : public TextureNode { std::shared_ptr createRenderData() override; }; +class ColorSpreadNode : public TextureNode { +public: + void init() override; + std::shared_ptr createRenderer() override; + std::shared_ptr createRenderData() override; +}; + class HeightBlendNode : public TextureNode { public: void init() override; diff --git a/src/texturelab/libraries/v3/spread.cpp b/src/texturelab/libraries/v3/spread.cpp new file mode 100644 index 00000000..f947d1cc --- /dev/null +++ b/src/texturelab/libraries/v3/spread.cpp @@ -0,0 +1,220 @@ +#include "../../graphics/noderenderer.h" +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +#include +#include + +// Color Spread — JFA nearest-seed lookup that samples a color map instead of +// computing a distance value. Each masked-out (black) pixel receives the exact +// color of the closest foreground pixel in the mask, producing solid filled +// regions with no gradient blending. +// +// Use case: flood-fill a pattern with random intensities, then spread those +// colors outward to cover the lines/gaps in the pattern. + +// ============================================================================ +// ColorSpreadRenderData +// ============================================================================ +struct ColorSpreadRenderData : public NodeRenderData { + float threshold = 0.5f; + float distance = 1.0f; +}; + +// ============================================================================ +// ColorSpreadRenderer +// ============================================================================ +class ColorSpreadRenderer : public NodeTextureRenderer { +public: + void render(NodeRenderContext& ctx, const NodeRenderData& baseData) override + { + auto& data = static_cast(baseData); + auto gl = ctx.gl; + auto cache = ctx.cache; + int w = ctx.textureWidth; + int h = ctx.textureHeight; + + // Require at least the mask input; color map is optional (falls back to + // mask) + if (ctx.inputs.isEmpty() || ctx.inputs[0].textureId == 0) { + cache->bindFboToTexture(ctx.outputTextureId); + gl->glViewport(0, 0, w, h); + gl->glClearColor(0, 0, 0, 1); + gl->glClear(GL_COLOR_BUFFER_BIT); + return; + } + + GLuint maskTex = ctx.inputs[0].textureId; + GLuint colorTex = + (ctx.inputs.size() > 1 && ctx.inputs[1].textureId != 0) + ? ctx.inputs[1].textureId + : maskTex; + + GLuint seedShader = cache->getOrCompileShader( + "cs_seed", RenderResourceCache::standardVertexSource(), seedFrag()); + GLuint jfaShader = cache->getOrCompileShader( + "cs_jfa", RenderResourceCache::standardVertexSource(), jfaFrag()); + GLuint sampleShader = cache->getOrCompileShader( + "cs_sample", RenderResourceCache::standardVertexSource(), + sampleFrag()); + + GLuint texA = cache->acquireTexture(w, h); + GLuint texB = cache->acquireTexture(w, h); + + // Seed pass: foreground pixels (mask >= threshold) write their UV; + // background pixels write the sentinel (-1, -1). + cache->bindFboToTexture(texA); + ctx.useShader(seedShader); + ctx.bindTexture(seedShader, "u_mask", maskTex, 0); + gl->glUniform1f(gl->glGetUniformLocation(seedShader, "u_threshold"), + data.threshold); + ctx.drawQuad(); + + // JFA passes: propagate nearest-seed UV across the texture + int maxDim = std::max(w, h); + int stepSize = maxDim / 2; + while (stepSize >= 1) { + cache->bindFboToTexture(texB); + ctx.useShader(jfaShader); + ctx.bindTexture(jfaShader, "u_input", texA, 0); + gl->glUniform1i(gl->glGetUniformLocation(jfaShader, "u_step"), + stepSize); + ctx.drawQuad(); + std::swap(texA, texB); + stepSize /= 2; + } + + // Sample pass: look up the color map at the nearest-seed UV + cache->bindFboToTexture(ctx.outputTextureId); + ctx.useShader(sampleShader); + ctx.bindTexture(sampleShader, "u_jfa", texA, 0); + ctx.bindTexture(sampleShader, "u_color", colorTex, 1); + gl->glUniform1f(gl->glGetUniformLocation(sampleShader, "u_distance"), + data.distance); + ctx.drawQuad(); + + cache->releaseTexture(texA); + cache->releaseTexture(texB); + } + +private: + static QString seedFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_mask; + uniform float u_threshold; + + void main() + { + float v = texture(u_mask, v_texCoord).r; + if (v >= u_threshold) + fragColor = vec4(v_texCoord, 0.0, 1.0); + else + fragColor = vec4(-1.0, -1.0, 0.0, 1.0); + } + )""""; + } + + static QString jfaFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_input; + uniform int u_step; + + void main() + { + vec2 texSize = vec2(textureSize(u_input, 0)); + vec2 step = vec2(float(u_step)) / texSize; + + vec2 bestUV = vec2(-1.0); + float bestDist = 1e9; + + for (int x = -1; x <= 1; x++) { + for (int y = -1; y <= 1; y++) { + vec2 sampleUV = v_texCoord + vec2(float(x), float(y)) * step; + vec4 s = texture(u_input, sampleUV); + vec2 seedUV = s.rg; + if (seedUV.x < 0.0) continue; + float d = length(v_texCoord - seedUV); + if (d < bestDist) { + bestDist = d; + bestUV = seedUV; + } + } + } + + fragColor = vec4(bestUV, 0.0, 1.0); + } + )""""; + } + + // Sample the color map at the nearest-seed UV for a solid, gradient-free + // fill + static QString sampleFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_jfa; + uniform sampler2D u_color; + uniform float u_distance; + + void main() + { + vec2 seedUV = texture(u_jfa, v_texCoord).rg; + + if (seedUV.x < 0.0) { + fragColor = vec4(0.0, 0.0, 0.0, 1.0); + } else { + float d = length(v_texCoord - seedUV); + if (d > u_distance * 0.5) + fragColor = vec4(0.0, 0.0, 0.0, 1.0); + else + fragColor = texture(u_color, seedUV); + } + } + )""""; + } +}; + +// ============================================================================ +// ColorSpreadNode +// ============================================================================ +void ColorSpreadNode::init() +{ + this->title = "Spread"; + + this->addInput("color"); + this->addInput("mask"); + + this->addFloatProp("threshold", "Threshold", 0.5, 0.0, 1.0, 0.01); + this->addFloatProp("distance", "Distance", 1.0, 0.0, 1.0, 0.01); +} + +std::shared_ptr ColorSpreadNode::createRenderer() +{ + return std::make_shared(); +} + +std::shared_ptr ColorSpreadNode::createRenderData() +{ + auto data = std::make_shared(); + + if (auto* p = dynamic_cast(getProp("threshold"))) + data->threshold = static_cast(p->value); + if (auto* p = dynamic_cast(getProp("distance"))) + data->distance = static_cast(p->value); + + return data; +} From 71afbf52b264feb5be91876bc6b2b9f40e5eee46 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 24 May 2026 23:34:12 -0500 Subject: [PATCH 058/164] add status bar with progress bar --- src/texturelab/graphics/texturerenderer.cpp | 10 +++++--- src/texturelab/graphics/texturerenderer.h | 1 + src/texturelab/mainwindow.cpp | 27 +++++++++++++++++++++ src/texturelab/mainwindow.h | 5 ++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/texturelab/graphics/texturerenderer.cpp b/src/texturelab/graphics/texturerenderer.cpp index ee7cb743..6d643d16 100644 --- a/src/texturelab/graphics/texturerenderer.cpp +++ b/src/texturelab/graphics/texturerenderer.cpp @@ -597,11 +597,15 @@ void TextureRenderer::initRenderWorker() void TextureRenderer::nodeRendered(const QString& nodeId, GLuint texId) { qDebug() << "TextureRenderer: Node rendered:" << nodeId; - // queue up next node to render emit thumbnailGenerated(nodeId, texId, QPixmap()); - this->queueNextNodeToRender(); - // QTimer::singleShot(0, this, &TextureRenderer::queueNextNodeToRender); + if (project) { + int total = project->nodes.size(); + int clean = 0; + for (const auto& n : project->nodes) + if (!n->isDirty) clean++; + emit renderProgress(clean, total); + } } void TextureRenderer::queueNextNodeToRender() diff --git a/src/texturelab/graphics/texturerenderer.h b/src/texturelab/graphics/texturerenderer.h index 980fcb76..1213084e 100644 --- a/src/texturelab/graphics/texturerenderer.h +++ b/src/texturelab/graphics/texturerenderer.h @@ -68,6 +68,7 @@ class TextureRenderer : public QObject { signals: void thumbnailGenerated(const QString& nodeId, GLuint texId, const QPixmap& pixmap); + void renderProgress(int clean, int total); }; // note: there's no specified fbo limit diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index fbde8fc8..a51fbffd 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -15,7 +15,11 @@ #include #include #include +#include +#include #include +#include +#include #include #include @@ -48,6 +52,15 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) this->renderer = nullptr; this->exportDialog = nullptr; + statusLabel = new QLabel("Ready"); + progressBar = new QProgressBar(); + progressBar->setRange(0, 1); + progressBar->setFixedWidth(180); + progressBar->setTextVisible(false); + progressBar->hide(); + statusBar()->addWidget(statusLabel); + statusBar()->addPermanentWidget(progressBar); + this->dockManager = new ads::CDockManager(this); this->setupDocks(); @@ -210,6 +223,20 @@ void MainWindow::setProject(TextureProjectPtr project) this->graphWidget->setTextureRenderer(renderer); this->view2DWidget->setTextureRenderer(renderer); + connect(renderer, &TextureRenderer::renderProgress, + [this](int clean, int total) { + if (total == 0 || clean == total) { + progressBar->hide(); + statusLabel->setText("Ready"); + } else { + progressBar->setMaximum(total); + progressBar->setValue(clean); + progressBar->show(); + statusLabel->setText( + QString("Rendering %1 / %2").arg(clean).arg(total)); + } + }); + // Update view3D textures when a node's texture is updated connect(renderer, &TextureRenderer::thumbnailGenerated, [this](const QString& nodeId, GLuint texId, const QPixmap&) { diff --git a/src/texturelab/mainwindow.h b/src/texturelab/mainwindow.h index eb7d9e1c..5e0f1402 100644 --- a/src/texturelab/mainwindow.h +++ b/src/texturelab/mainwindow.h @@ -20,6 +20,8 @@ class TextureProject; typedef QSharedPointer TextureProjectPtr; class QToolBar; +class QProgressBar; +class QLabel; class MainWindow : public QMainWindow { Q_OBJECT @@ -69,6 +71,9 @@ class MainWindow : public QMainWindow { TextureRenderer* renderer; + QProgressBar* progressBar; + QLabel* statusLabel; + TextureProjectPtr project; }; #endif // MAINWINDOW_H From 6006931c20a53e5847ee25b4e3636861f774856b Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Mon, 25 May 2026 00:04:12 -0500 Subject: [PATCH 059/164] fix status bar alignment --- src/texturelab/mainwindow.cpp | 53 ++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index a51fbffd..2a50726f 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -14,9 +15,8 @@ #include #include #include -#include -#include #include +#include #include #include #include @@ -55,11 +55,19 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) statusLabel = new QLabel("Ready"); progressBar = new QProgressBar(); progressBar->setRange(0, 1); + progressBar->setValue(1); progressBar->setFixedWidth(180); progressBar->setTextVisible(false); - progressBar->hide(); - statusBar()->addWidget(statusLabel); - statusBar()->addPermanentWidget(progressBar); + + auto* statusWidget = new QWidget(); + auto* statusLayout = new QHBoxLayout(statusWidget); + // statusLayout->setContentsMargins(4, 0, 4, 0); + statusLayout->setContentsMargins(0, 0, 0, 0); + statusLayout->setSpacing(6); + statusLayout->addStretch(); + statusLayout->addWidget(statusLabel, 0, Qt::AlignVCenter); + statusLayout->addWidget(progressBar, 0, Qt::AlignVCenter); + statusBar()->addWidget(statusWidget, 1); this->dockManager = new ads::CDockManager(this); @@ -225,13 +233,12 @@ void MainWindow::setProject(TextureProjectPtr project) connect(renderer, &TextureRenderer::renderProgress, [this](int clean, int total) { + progressBar->setMaximum(total == 0 ? 1 : total); + progressBar->setValue(total == 0 ? 1 : clean); if (total == 0 || clean == total) { - progressBar->hide(); statusLabel->setText("Ready"); - } else { - progressBar->setMaximum(total); - progressBar->setValue(clean); - progressBar->show(); + } + else { statusLabel->setText( QString("Rendering %1 / %2").arg(clean).arg(total)); } @@ -291,8 +298,8 @@ void MainWindow::setupMenus() fileMenu->addSeparator(); recentFilesMenu = fileMenu->addMenu("Open Recent"); - connect(recentFilesMenu, &QMenu::aboutToShow, - this, &MainWindow::updateRecentFilesMenu); + connect(recentFilesMenu, &QMenu::aboutToShow, this, + &MainWindow::updateRecentFilesMenu); fileMenu->addSeparator(); fileMenu->addAction("Edit", []() {}); @@ -710,14 +717,15 @@ void MainWindow::updateRecentFilesMenu() for (const QString& filePath : files) { QFileInfo info(filePath); - auto action = recentFilesMenu->addAction(info.fileName(), [this, filePath]() { - auto project = Project::loadTexture(filePath); - QFileInfo fileInfo(filePath); - project->name = fileInfo.baseName(); - project->filePath = filePath; - setProject(project); - addToRecentFiles(filePath); - }); + auto action = + recentFilesMenu->addAction(info.fileName(), [this, filePath]() { + auto project = Project::loadTexture(filePath); + QFileInfo fileInfo(filePath); + project->name = fileInfo.baseName(); + project->filePath = filePath; + setProject(project); + addToRecentFiles(filePath); + }); action->setToolTip(filePath); } @@ -725,9 +733,8 @@ void MainWindow::updateRecentFilesMenu() recentFilesMenu->addAction("No recent files")->setEnabled(false); recentFilesMenu->addSeparator(); - recentFilesMenu->addAction("Clear Recent Files", [this]() { - QSettings().remove("recentFiles"); - }); + recentFilesMenu->addAction("Clear Recent Files", + [this]() { QSettings().remove("recentFiles"); }); } MainWindow::~MainWindow() From c1f83c1a8093485400dc2b4f0fbf190f7b53bc45 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Fri, 29 May 2026 14:06:29 -0500 Subject: [PATCH 060/164] add github build script --- gh-build.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100755 gh-build.sh diff --git a/gh-build.sh b/gh-build.sh new file mode 100755 index 00000000..c27b2c22 --- /dev/null +++ b/gh-build.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO="njbrown/texturelab" +WORKFLOW="build.yml" +BRANCH="${1:-$(git rev-parse --abbrev-ref HEAD)}" + +echo "Triggering build for branch: $BRANCH" +gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$BRANCH" + +echo "Waiting for run to start..." +sleep 3 + +RUN_ID=$(gh run list --repo "$REPO" --workflow "$WORKFLOW" --branch "$BRANCH" --limit 1 --json databaseId -q '.[0].databaseId') + +echo "Run ID: $RUN_ID" +echo "https://github.com/$REPO/actions/runs/$RUN_ID" + +if [[ "${2:-}" == "--watch" || "${1:-}" == "--watch" ]]; then + gh run watch "$RUN_ID" --repo "$REPO" +fi From b1d0e0b08039b061cd6663b90721a5196a1ba5b7 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 30 May 2026 18:19:51 -0500 Subject: [PATCH 061/164] notify discord on successful build --- .github/workflows/build.yml | 53 +++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1d6bb802..3a51f6cd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -155,3 +155,56 @@ jobs: with: name: texturelab-macos path: build/src/texturelab/texturelab.app + + notify-discord: + needs: [build-linux, build-windows, build-macos] + if: always() + runs-on: ubuntu-latest + steps: + - name: Post to Discord + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + BRANCH: ${{ github.ref_name }} + SHA: ${{ github.sha }} + RUN_ID: ${{ github.run_id }} + REPO: ${{ github.repository }} + LINUX_RESULT: ${{ needs.build-linux.result }} + WINDOWS_RESULT: ${{ needs.build-windows.result }} + MACOS_RESULT: ${{ needs.build-macos.result }} + run: | + SHORT_SHA="${SHA:0:7}" + RUN_URL="https://github.com/$REPO/actions/runs/$RUN_ID" + + status_emoji() { + case "$1" in + success) echo "✅" ;; + failure) echo "❌" ;; + cancelled) echo "⏹️" ;; + *) echo "⚠️" ;; + esac + } + + if [[ "$LINUX_RESULT" == "success" && "$WINDOWS_RESULT" == "success" && "$MACOS_RESULT" == "success" ]]; then + TITLE="Build succeeded — $BRANCH @ $SHORT_SHA" + COLOR=3066993 + LINUX_URL="https://nightly.link/$REPO/workflows/build/$BRANCH/texturelab-linux.zip" + WINDOWS_URL="https://nightly.link/$REPO/workflows/build/$BRANCH/texturelab-windows.zip" + MACOS_URL="https://nightly.link/$REPO/workflows/build/$BRANCH/texturelab-macos.zip" + DOWNLOADS="[Linux]($LINUX_URL) · [Windows]($WINDOWS_URL) · [macOS]($MACOS_URL)" + DESCRIPTION="$DOWNLOADS" + else + TITLE="Build failed — $BRANCH @ $SHORT_SHA" + COLOR=15158332 + DESCRIPTION="$(status_emoji $LINUX_RESULT) Linux · $(status_emoji $WINDOWS_RESULT) Windows · $(status_emoji $MACOS_RESULT) macOS\n[View run]($RUN_URL)" + fi + + curl -s -X POST "$DISCORD_WEBHOOK" \ + -H "Content-Type: application/json" \ + -d "{ + \"embeds\": [{ + \"title\": \"$TITLE\", + \"description\": \"$DESCRIPTION\", + \"color\": $COLOR, + \"url\": \"$RUN_URL\" + }] + }" From 9df25f6279a55b3e2d57c244bd5a96af2b265996 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 30 May 2026 18:35:46 -0500 Subject: [PATCH 062/164] switch to aws for nightlies --- .github/workflows/build.yml | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3a51f6cd..c2355f5c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -156,11 +156,34 @@ jobs: name: texturelab-macos path: build/src/texturelab/texturelab.app - notify-discord: + deploy: needs: [build-linux, build-windows, build-macos] if: always() runs-on: ubuntu-latest steps: + - name: Download artifacts + if: ${{ needs.build-linux.result == 'success' || needs.build-windows.result == 'success' || needs.build-macos.result == 'success' }} + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Zip and upload to S3 + if: ${{ needs.build-linux.result == 'success' || needs.build-windows.result == 'success' || needs.build-macos.result == 'success' }} + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: us-east-2 + SHA: ${{ github.sha }} + run: | + cd artifacts + if [ -d texturelab-linux ]; then zip -r ../linux-$SHA.zip texturelab-linux/; fi + if [ -d texturelab-windows ]; then zip -r ../windows-$SHA.zip texturelab-windows/; fi + if [ -d texturelab-macos ]; then zip -r ../macos-$SHA.zip texturelab-macos/; fi + cd .. + for f in linux-$SHA.zip windows-$SHA.zip macos-$SHA.zip; do + [ -f "$f" ] && aws s3 cp "$f" s3://texturelab-nightlies/ + done + - name: Post to Discord env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} @@ -174,6 +197,7 @@ jobs: run: | SHORT_SHA="${SHA:0:7}" RUN_URL="https://github.com/$REPO/actions/runs/$RUN_ID" + S3_BASE="https://texturelab-nightlies.s3.us-east-2.amazonaws.com" status_emoji() { case "$1" in @@ -187,11 +211,7 @@ jobs: if [[ "$LINUX_RESULT" == "success" && "$WINDOWS_RESULT" == "success" && "$MACOS_RESULT" == "success" ]]; then TITLE="Build succeeded — $BRANCH @ $SHORT_SHA" COLOR=3066993 - LINUX_URL="https://nightly.link/$REPO/workflows/build/$BRANCH/texturelab-linux.zip" - WINDOWS_URL="https://nightly.link/$REPO/workflows/build/$BRANCH/texturelab-windows.zip" - MACOS_URL="https://nightly.link/$REPO/workflows/build/$BRANCH/texturelab-macos.zip" - DOWNLOADS="[Linux]($LINUX_URL) · [Windows]($WINDOWS_URL) · [macOS]($MACOS_URL)" - DESCRIPTION="$DOWNLOADS" + DESCRIPTION="[Linux]($S3_BASE/linux-$SHA.zip) · [Windows]($S3_BASE/windows-$SHA.zip) · [macOS]($S3_BASE/macos-$SHA.zip)" else TITLE="Build failed — $BRANCH @ $SHORT_SHA" COLOR=15158332 From 756e687800e5cec3767c041d14b814081399d93f Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 31 May 2026 00:44:56 -0500 Subject: [PATCH 063/164] add experimental v3 normal map node --- src/texturelab/CMakeLists.txt | 1 + src/texturelab/libraries/library.cpp | 2 + src/texturelab/libraries/libv3.h | 5 ++ src/texturelab/libraries/v3/normalmapv3.cpp | 87 +++++++++++++++++++++ 4 files changed, 95 insertions(+) create mode 100644 src/texturelab/libraries/v3/normalmapv3.cpp diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index e64d2206..c8c2fad1 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -133,6 +133,7 @@ set(LIBRARYV3 ./libraries/v3/spread.cpp ./libraries/v3/heightblend.cpp ./libraries/v3/makeittile.cpp + ./libraries/v3/normalmapv3.cpp ) set(PROJECT_SOURCES diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index 5d3bb4d0..acf0ba98 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -268,6 +268,8 @@ Library* createLibraryV3() ":nodes/blend.png"); lib->addNode("makeittile", "Make It Tile", ":nodes/tile.png"); + // lib->addNode("normalmapv3", "Normal Map V3", + // ":nodes/normalmap.png"); return lib; } \ No newline at end of file diff --git a/src/texturelab/libraries/libv3.h b/src/texturelab/libraries/libv3.h index 86f97e2e..79abe279 100644 --- a/src/texturelab/libraries/libv3.h +++ b/src/texturelab/libraries/libv3.h @@ -109,6 +109,11 @@ class MakeItTileNode : public TextureNode { void init() override; }; +class NormalMapV3Node : public TextureNode { +public: + void init() override; +}; + class BevelV2Node : public TextureNode { public: virtual void init() override; diff --git a/src/texturelab/libraries/v3/normalmapv3.cpp b/src/texturelab/libraries/v3/normalmapv3.cpp new file mode 100644 index 00000000..898c0fe5 --- /dev/null +++ b/src/texturelab/libraries/v3/normalmapv3.cpp @@ -0,0 +1,87 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void NormalMapV3Node::init() +{ + this->title = "Normal Map"; + this->addInput("height"); + + this->addEnumProp("filter", "Filter", {"Simple", "Sobel", "Scharr"}); + this->addEnumProp("channel", "Channel", {"Red", "Green", "Blue", "Alpha", "Luminance"}); + this->addFloatProp("strength", "Strength", 1.0, -4.0, 4.0, 0.05); + this->addIntProp("range", "Range", 1, 1, 20, 1); + this->addBoolProp("res_ind", "Resolution Independent", false); + this->addIntProp("ref_res", "Reference Resolution", 1024, 64, 8192, 64); + this->addEnumProp("convention", "Convention", {"OpenGL (Y+)", "DirectX (Y-)"}); + + auto source = R""""( + // Extract a single channel from the height input. + // Channel: 0=R 1=G 2=B 3=A 4=Luminance(BT.709) + float sampleChannel(vec2 uv) + { + vec4 s = texture(height, uv); + if (prop_channel == 1) return s.g; + if (prop_channel == 2) return s.b; + if (prop_channel == 3) return s.a; + if (prop_channel == 4) return dot(s.rgb, vec3(0.2126, 0.7152, 0.0722)); + return s.r; + } + + vec4 process(vec2 uv) + { + vec2 step = (vec2(1.0) / _textureSize) * float(prop_range); + if (prop_res_ind) + step = (vec2(1.0) / float(prop_ref_res)) * float(prop_range); + + // Scale matches V2's effective strength (prop_strength * 0.1 / 2.0). + // The cross-product construction below naturally incorporates step.x + // into the z-component, keeping XY and Z in the same magnitude range. + float scale = prop_strength * 0.05; + float gx, gy; + + if (prop_filter == 0) { + // Simple: forward-difference (3-tap), max response = 1.0 + float c = sampleChannel(uv); + float r = sampleChannel(uv + vec2( step.x, 0.0)); + float u = sampleChannel(uv + vec2( 0.0, step.y)); + gx = r - c; + gy = u - c; + } else { + // 3x3 neighbourhood for Sobel / Scharr + float tl = sampleChannel(uv + vec2(-step.x, step.y)); + float tc = sampleChannel(uv + vec2( 0.0, step.y)); + float tr = sampleChannel(uv + vec2( step.x, step.y)); + float ml = sampleChannel(uv + vec2(-step.x, 0.0)); + float mr = sampleChannel(uv + vec2( step.x, 0.0)); + float bl = sampleChannel(uv + vec2(-step.x, -step.y)); + float bc = sampleChannel(uv + vec2( 0.0, -step.y)); + float br = sampleChannel(uv + vec2( step.x, -step.y)); + + if (prop_filter == 1) { + // Sobel — max response = 4.0, normalise so strength is + // perceptually equivalent to Simple at the same value + gx = (-tl + tr - 2.0*ml + 2.0*mr - bl + br) / 4.0; + gy = ( tl + 2.0*tc + tr - bl - 2.0*bc - br) / 4.0; + } else { + // Scharr — max response = 16.0 + gx = (-3.0*tl + 3.0*tr - 10.0*ml + 10.0*mr - 3.0*bl + 3.0*br) / 16.0; + gy = ( 3.0*tl + 10.0*tc + 3.0*tr - 3.0*bl - 10.0*bc - 3.0*br) / 16.0; + } + } + + // Cross product of surface tangent vectors — step.x in the z slot keeps + // the XY/Z ratio consistent regardless of texture resolution or range. + vec3 dvx = vec3(step.x, 0.0, gx * scale); + vec3 dvy = vec3(0.0, step.y, gy * scale); + vec3 normal = normalize(cross(dvx, dvy)); + + // Convention: OpenGL = Y+ (green-up), DirectX = Y- (green-down) + if (prop_convention == 1) normal.y = -normal.y; + + return vec4(normal * 0.5 + 0.5, 1.0); + } + )""""; + + this->setShaderSource(source); +} From 6c3841e1d1a462cfbebb8f03649d6749b358861b Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 31 May 2026 00:49:58 -0500 Subject: [PATCH 064/164] comment out makeittile --- src/texturelab/libraries/library.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index acf0ba98..016cedc0 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -266,8 +266,8 @@ Library* createLibraryV3() lib->addNode("spread", "Spread", ":nodes/bevel.png"); lib->addNode("heightblend", "Height Blend", ":nodes/blend.png"); - lib->addNode("makeittile", "Make It Tile", - ":nodes/tile.png"); + // lib->addNode("makeittile", "Make It Tile", + // ":nodes/tile.png"); // lib->addNode("normalmapv3", "Normal Map V3", // ":nodes/normalmap.png"); From b257ecb473af8176f7debd7f360882af9dc78421 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 31 May 2026 01:35:25 -0500 Subject: [PATCH 065/164] refine gaussian blur --- src/texturelab/libraries/v3/blurhq.cpp | 71 +++++++++++++++++++++----- 1 file changed, 57 insertions(+), 14 deletions(-) diff --git a/src/texturelab/libraries/v3/blurhq.cpp b/src/texturelab/libraries/v3/blurhq.cpp index 19d9f5d6..69edba24 100644 --- a/src/texturelab/libraries/v3/blurhq.cpp +++ b/src/texturelab/libraries/v3/blurhq.cpp @@ -52,13 +52,24 @@ class BlurHQRenderer : public NodeTextureRenderer { RenderResourceCache::standardVertexSource(), verticalFrag()); - // Intermediate texture for the horizontal pass result + // Intermediate texture for the horizontal pass result. + // GL_LINEAR is required for the bilinear tap trick in the shaders — + // sampling at fractional offsets must interpolate rather than snap. GLuint intermediate = cache->acquireTexture(w, h); + gl->glBindTexture(GL_TEXTURE_2D, intermediate); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + + GLuint inputTex = ctx.inputs[0].textureId; + gl->glBindTexture(GL_TEXTURE_2D, inputTex); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + gl->glBindTexture(GL_TEXTURE_2D, 0); // --- Pass 1: horizontal blur --- cache->bindFboToTexture(intermediate); ctx.useShader(hShader); - ctx.bindTexture(hShader, "u_image", ctx.inputs[0].textureId, 0); + ctx.bindTexture(hShader, "u_image", inputTex, 0); gl->glUniform1f( gl->glGetUniformLocation(hShader, "u_radius"), data.radius); gl->glUniform2f( @@ -77,11 +88,28 @@ class BlurHQRenderer : public NodeTextureRenderer { float(w), float(h)); ctx.drawQuad(); + // Restore GL_NEAREST on both textures — pooled textures are expected + // to be GL_NEAREST; the input texture is owned by another node. + gl->glBindTexture(GL_TEXTURE_2D, inputTex); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + gl->glBindTexture(GL_TEXTURE_2D, intermediate); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + gl->glBindTexture(GL_TEXTURE_2D, 0); + cache->releaseTexture(intermediate); } private: - // Shared Gaussian sampling code — axis is injected per-pass + // Shared Gaussian sampling code — axis is injected per-pass. + // + // Uses the bilinear tap trick: instead of sampling at each integer pixel + // offset i, adjacent taps i and i+1 are collapsed into a single fetch at + // the Gaussian-weighted midpoint between them. Hardware bilinear filtering + // then delivers the exact weighted average in one sample, halving fetch + // count and eliminating the discrete-step banding that arises from + // integer-only sampling on smooth gradients. static QString gaussianBody(const QString& axis) { return QString(R""""( @@ -101,17 +129,32 @@ class BlurHQRenderer : public NodeTextureRenderer { // Scale radius by resolution so the blur covers the same // visual proportion regardless of texture size (512 = reference). float pixelRadius = u_radius * (_textureSize.x / 512.0); - float sigma = max(pixelRadius / 3.0, 0.001); - float twoSigSq = 2.0 * sigma * sigma; - vec4 result = vec4(0.0); - float totalW = 0.0; - - int radius = int(ceil(pixelRadius)); - for (int i = -radius; i <= radius; i++) { - float w = exp(-float(i * i) / twoSigSq); - vec2 offset = %1 * float(i); - result += texture(u_image, fract(uv + offset * step)) * w; - totalW += w; + float sigma = max(pixelRadius / 3.0, 0.001); + float twoSigSq = 2.0 * sigma * sigma; + + // Center tap: G(0) = 1, no offset needed. + vec4 result = texture(u_image, uv); + float totalW = 1.0; + + int iRadius = int(ceil(pixelRadius)); + + // Bilinear tap trick: pair taps i and i+1, sample once at the + // weighted midpoint. GL_LINEAR on u_image makes the hardware + // perform the blend. Use float(i)*float(i) to avoid any + // potential int overflow at large radii. + for (int i = 1; i <= iRadius; i += 2) { + float fi = float(i); + float w0 = exp(-(fi * fi) / twoSigSq); + float w1 = (i + 1 <= iRadius) + ? exp(-((fi + 1.0) * (fi + 1.0)) / twoSigSq) + : 0.0; + float w = w0 + w1; + float offset = fi + w1 / w; + + vec2 o = %1 * offset * step; + result += texture(u_image, fract(uv + o)) * w; + result += texture(u_image, fract(uv - o)) * w; + totalW += 2.0 * w; } fragColor = result / max(totalW, 0.0001); From ab248ba6727942cce93882c1553aa028dd0e1bc3 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 31 May 2026 02:00:25 -0500 Subject: [PATCH 066/164] add perlin noise --- src/texturelab/CMakeLists.txt | 1 + src/texturelab/libraries/library.cpp | 2 + src/texturelab/libraries/libv3.h | 5 ++ src/texturelab/libraries/v3/perlinnoise.cpp | 99 +++++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 src/texturelab/libraries/v3/perlinnoise.cpp diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index c8c2fad1..d6f47345 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -127,6 +127,7 @@ set(LIBRARYV3 ./libraries/v3/voronoifractal.cpp ./libraries/v3/truchet.cpp ./libraries/v3/fbmdomainwarp.cpp + ./libraries/v3/perlinnoise.cpp # Phase 3 — Multi-pass ./libraries/v3/blurhq.cpp ./libraries/v3/distancetransform.cpp diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index 016cedc0..5f3cb116 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -256,6 +256,8 @@ Library* createLibraryV3() lib->addNode("truchet", "Truchet", ":nodes/hexagon.png"); lib->addNode("fbmdomainwarp", "FBM Domain Warp", ":nodes/fractalnoise.png"); + lib->addNode("perlinnoise", "Perlin Noise", + ":nodes/fractalnoise.png"); // ----------------------------------------------------------------------- // Phase 3 — Multi-pass diff --git a/src/texturelab/libraries/libv3.h b/src/texturelab/libraries/libv3.h index 79abe279..f68192cc 100644 --- a/src/texturelab/libraries/libv3.h +++ b/src/texturelab/libraries/libv3.h @@ -74,6 +74,11 @@ class FBMDomainWarpNode : public TextureNode { void init() override; }; +class PerlinNoiseNode : public TextureNode { +public: + void init() override; +}; + // --------------------------------------------------------------------------- // Phase 3 — Multi-pass nodes // --------------------------------------------------------------------------- diff --git a/src/texturelab/libraries/v3/perlinnoise.cpp b/src/texturelab/libraries/v3/perlinnoise.cpp new file mode 100644 index 00000000..d66f2312 --- /dev/null +++ b/src/texturelab/libraries/v3/perlinnoise.cpp @@ -0,0 +1,99 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Classic 2D gradient (Perlin) noise, upgraded for v3: +// - Quintic smooth step: C2-continuous, no gradient discontinuities at cell +// edges +// - Normalized pseudo-random gradients via hash22 → unit circle +// - Seamless tiling: integer grid + mod()-wrapped cell indices +// - fBm with per-octave lacunarity / gain controls +// - Three output modes: Standard, Ridged, Billowy +// +// References: +// Perlin, "An Image Synthesizer," SIGGRAPH 1985 +// https://iquilezles.org/articles/gradientnoise/ — gradient noise primer +void PerlinNoiseNode::init() +{ + this->title = "Perlin Noise"; + + auto modeProp = this->addEnumProp("output_mode", "Output", + {"Standard", "Ridged", "Billowy"}); + modeProp->index = 0; + + this->addIntProp("scale", "Scale", 4, 1, 12, 1); + this->addIntProp("octaves", "Octaves", 6, 1, 8, 1); + this->addFloatProp("lacunarity", "Lacunarity", 2.0, 1.5, 4.0, 0.1); + this->addFloatProp("gain", "Gain", 0.5, 0.2, 0.8, 0.05); + + auto source = R""""( + #define OUT_STANDARD 0 + #define OUT_RIDGED 1 + #define OUT_BILLOWY 2 + + // 2D gradient noise. + // gridSize must be a positive integer so that mod() wrapping produces + // seamless tiling: cell (gridSize) hashes identically to cell 0. + float gnoise(vec2 uv, float gridSize) { + vec2 p = uv * gridSize; + vec2 i = floor(p); + vec2 f = fract(p); + + // Quintic interpolant: 6t^5 - 15t^4 + 10t^3 (zero first & second derivative at 0 and 1) + vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + + vec2 sOff = vec2(_seed * 0.173, _seed * 0.251); + vec2 gs = vec2(gridSize); + + // Unit-circle gradients from hash22 (maps [0,1]^2 → [-1,1]^2, then normalize) + vec2 g00 = normalize(-1.0 + 2.0 * hash22(mod(i, gs) + sOff)); + vec2 g10 = normalize(-1.0 + 2.0 * hash22(mod(i + vec2(1,0), gs) + sOff)); + vec2 g01 = normalize(-1.0 + 2.0 * hash22(mod(i + vec2(0,1), gs) + sOff)); + vec2 g11 = normalize(-1.0 + 2.0 * hash22(mod(i + vec2(1,1), gs) + sOff)); + + // Dot gradient with distance-to-corner vector + float n00 = dot(g00, f); + float n10 = dot(g10, f - vec2(1,0)); + float n01 = dot(g01, f - vec2(0,1)); + float n11 = dot(g11, f - vec2(1,1)); + + // Scale ~1/sqrt(0.5) ≈ 1.41 to stretch typical ±0.7 output to ±1 + return 1.41 * mix(mix(n00, n10, u.x), mix(n01, n11, u.x), u.y); + } + + vec4 process(vec2 uv) { + float value = 0.0; + float amp = 0.5; + float freq = float(prop_scale); + float maxAmp = 0.0; + + for (int i = 0; i < prop_octaves; i++) { + // Round to integer so every octave grid tiles exactly + float gs = max(1.0, floor(freq + 0.5)); + float n = gnoise(uv, gs); // in approximately [-1, 1] + + if (prop_output_mode == OUT_RIDGED) + n = 1.0 - abs(n); // sharp ridges at zero-crossings + else if (prop_output_mode == OUT_BILLOWY) + n = abs(n); // rounded bumps everywhere + + value += amp * n; + maxAmp += amp; + freq *= prop_lacunarity; + amp *= prop_gain; + } + + float f = value / maxAmp; + + float result; + if (prop_output_mode == OUT_STANDARD) + result = clamp(0.5 + 0.5 * f, 0.0, 1.0); + else + result = clamp(f, 0.0, 1.0); + + return vec4(vec3(result), 1.0); + } + )""""; + + this->setShaderSource(source); +} From cc949c01e8ed4d98534e919dfc1d002370033c5e Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 31 May 2026 02:10:10 -0500 Subject: [PATCH 067/164] add perlin noise 3d --- src/texturelab/CMakeLists.txt | 1 + src/texturelab/libraries/library.cpp | 2 + src/texturelab/libraries/libv3.h | 5 + src/texturelab/libraries/v3/perlinnoise3d.cpp | 151 ++++++++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 src/texturelab/libraries/v3/perlinnoise3d.cpp diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index d6f47345..b66f99d4 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -128,6 +128,7 @@ set(LIBRARYV3 ./libraries/v3/truchet.cpp ./libraries/v3/fbmdomainwarp.cpp ./libraries/v3/perlinnoise.cpp + ./libraries/v3/perlinnoise3d.cpp # Phase 3 — Multi-pass ./libraries/v3/blurhq.cpp ./libraries/v3/distancetransform.cpp diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index 5f3cb116..8848289a 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -258,6 +258,8 @@ Library* createLibraryV3() ":nodes/fractalnoise.png"); lib->addNode("perlinnoise", "Perlin Noise", ":nodes/fractalnoise.png"); + lib->addNode("perlinnoise3d", "Perlin Noise 3D", + ":nodes/fractalnoise.png"); // ----------------------------------------------------------------------- // Phase 3 — Multi-pass diff --git a/src/texturelab/libraries/libv3.h b/src/texturelab/libraries/libv3.h index f68192cc..80f2848a 100644 --- a/src/texturelab/libraries/libv3.h +++ b/src/texturelab/libraries/libv3.h @@ -79,6 +79,11 @@ class PerlinNoiseNode : public TextureNode { void init() override; }; +class PerlinNoise3DNode : public TextureNode { +public: + void init() override; +}; + // --------------------------------------------------------------------------- // Phase 3 — Multi-pass nodes // --------------------------------------------------------------------------- diff --git a/src/texturelab/libraries/v3/perlinnoise3d.cpp b/src/texturelab/libraries/v3/perlinnoise3d.cpp new file mode 100644 index 00000000..ef674cce --- /dev/null +++ b/src/texturelab/libraries/v3/perlinnoise3d.cpp @@ -0,0 +1,151 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// 3D gradient (Perlin) noise for texture work. +// +// The core insight: sampling a 2D cross-section through a 3D noise volume +// gives patterns that a purely 2D noise cannot — specifically wood grain and +// marble veins, the two applications Perlin described in his original 1985 +// paper. The Z Slice prop lets you navigate through the volume; rotating +// the cut angle gives completely different grain/vein patterns from the same +// seed, just as cutting a log at different angles produces different grain. +// +// Tiling: XY cell indices are mod()-wrapped at the integer grid boundary so +// the noise repeats seamlessly. Z is intentionally left free — tiling in Z +// would create visible repetition along the depth axis. +// +// Modes: +// Standard — raw fBm, remapped to [0,1] +// Ridged — 1 - |n| per octave → sharp mountain-ridge crests +// Billowy — |n| per octave → rounded pillow-cloud bumps +// Wood — noise-distorted concentric rings (log cross-section) +// Marble — noise-distorted sine stripes (classic Perlin marble veining) +// +// References: +// Perlin, "An Image Synthesizer," SIGGRAPH 1985 +// https://iquilezles.org/articles/gradientnoise/ +void PerlinNoise3DNode::init() +{ + this->title = "Perlin Noise 3D"; + + auto modeProp = this->addEnumProp("output_mode", "Output", + {"Standard", "Ridged", "Billowy", + "Wood", "Marble"}); + modeProp->index = 0; + + this->addIntProp ("scale", "Scale", 4, 1, 12, 1); + this->addFloatProp("z_offset", "Z Slice", 0.0, 0.0, 10.0, 0.1); + this->addIntProp ("octaves", "Octaves", 6, 1, 8, 1); + this->addFloatProp("lacunarity", "Lacunarity", 2.0, 1.5, 4.0, 0.1); + this->addFloatProp("gain", "Gain", 0.5, 0.2, 0.8, 0.05); + this->addFloatProp("distortion", "Distortion", 2.0, 0.0, 8.0, 0.1); + + auto source = R""""( + #define OUT_STANDARD 0 + #define OUT_RIDGED 1 + #define OUT_BILLOWY 2 + #define OUT_WOOD 3 + #define OUT_MARBLE 4 + + // Unit-sphere gradient for a 3D cell. + // XY indices are mod()-wrapped for seamless XY tiling; Z is free. + vec3 grad3(vec3 cell, float gridSize) { + vec2 wrapped = mod(cell.xy, vec2(gridSize)); + vec3 p = vec3(wrapped, cell.z) + + vec3(_seed * 0.173, _seed * 0.251, _seed * 0.317); + p = fract(p * vec3(0.1031, 0.1030, 0.0973)); + p += dot(p, p.yzx + 33.33); + return normalize(fract((p.xxy + p.yxx) * p.zyx) * 2.0 - 1.0); + } + + // 3D gradient noise with quintic interpolation. + // Scale factor 1.155 ≈ 1/sqrt(0.75): normalises the theoretical + // max of sqrt(3/4) for unit gradients in 3D to approximately ±1. + float gnoise3(vec2 uv, float z, float gridSize) { + vec3 p = vec3(uv * gridSize, z); + vec3 i = floor(p); + vec3 f = fract(p); + vec3 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + + float n000 = dot(grad3(i + vec3(0,0,0), gridSize), f - vec3(0,0,0)); + float n100 = dot(grad3(i + vec3(1,0,0), gridSize), f - vec3(1,0,0)); + float n010 = dot(grad3(i + vec3(0,1,0), gridSize), f - vec3(0,1,0)); + float n110 = dot(grad3(i + vec3(1,1,0), gridSize), f - vec3(1,1,0)); + float n001 = dot(grad3(i + vec3(0,0,1), gridSize), f - vec3(0,0,1)); + float n101 = dot(grad3(i + vec3(1,0,1), gridSize), f - vec3(1,0,1)); + float n011 = dot(grad3(i + vec3(0,1,1), gridSize), f - vec3(0,1,1)); + float n111 = dot(grad3(i + vec3(1,1,1), gridSize), f - vec3(1,1,1)); + + return 1.155 * mix( + mix(mix(n000, n100, u.x), mix(n010, n110, u.x), u.y), + mix(mix(n001, n101, u.x), mix(n011, n111, u.x), u.y), + u.z); + } + + // fBm over gnoise3. Ridged/Billowy transformations are applied + // per-octave so the feedback shapes fine detail correctly. + // Wood/Marble use the raw accumulated noise as turbulence. + float fbm3(vec2 uv, float z) { + float value = 0.0; + float amp = 0.5; + float freq = float(prop_scale); + float zScale = 1.0; + float maxAmp = 0.0; + + for (int i = 0; i < prop_octaves; i++) { + float gs = max(1.0, floor(freq + 0.5)); + float n = gnoise3(uv, z * zScale, gs); + + if (prop_output_mode == OUT_RIDGED) + n = 1.0 - abs(n); + else if (prop_output_mode == OUT_BILLOWY) + n = abs(n); + + value += amp * n; + maxAmp += amp; + freq *= prop_lacunarity; + zScale *= prop_lacunarity; + amp *= prop_gain; + } + + return value / maxAmp; + } + + vec4 process(vec2 uv) { + float f = fbm3(uv, prop_z_offset); + + float result; + + if (prop_output_mode == OUT_STANDARD) { + result = clamp(0.5 + 0.5 * f, 0.0, 1.0); + + } else if (prop_output_mode == OUT_RIDGED || + prop_output_mode == OUT_BILLOWY) { + result = clamp(f, 0.0, 1.0); + + } else if (prop_output_mode == OUT_WOOD) { + // Concentric rings centered on UV (0.5, 0.5), distorted by + // the noise turbulence. Scale drives ring frequency; + // Distortion controls how wavy the rings become. + float rings = length(uv - 0.5) * float(prop_scale) * 2.0 + + f * prop_distortion; + result = 0.5 + 0.5 * cos(rings * 6.28318530); + + } else { // MARBLE + // Horizontal bands distorted by noise turbulence — the + // canonical Perlin marble. Scale controls vein frequency; + // Distortion controls how much the noise bends the veins. + float vein = uv.x * float(prop_scale) + + f * prop_distortion; + float m = sin(vein * 3.14159265); + // Gamma curve sharpens vein edges for a more realistic look + result = clamp(pow(abs(m), 0.6) * sign(m) * 0.5 + 0.5, 0.0, 1.0); + } + + return vec4(vec3(result), 1.0); + } + )""""; + + this->setShaderSource(source); +} From b5a73199f1aaf5795f42c027beefee47a7409118 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 31 May 2026 02:13:04 -0500 Subject: [PATCH 068/164] remove old perlin2d --- src/texturelab/libraries/library.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index 8848289a..b65cbada 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -199,6 +199,7 @@ Library* createLibraryV3() lib->items.remove("floodfilltorandomcolor"); lib->items.remove("floodfilltorandomintensity"); lib->items.remove("bevel"); + lib->items.remove("perlin3d"); // V3 NODES lib->addNode("bevelv2", "Bevel V2", ":nodes/bevel.png"); From 6a06105ff6e208e4bc7b2dec1ea94a0c86b95421 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 31 May 2026 22:13:30 -0500 Subject: [PATCH 069/164] add blend node --- src/texturelab/CMakeLists.txt | 1 + src/texturelab/libraries/library.cpp | 1 + src/texturelab/libraries/libv3.h | 5 + src/texturelab/libraries/v3/blendv3.cpp | 120 ++++++++++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 src/texturelab/libraries/v3/blendv3.cpp diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index b66f99d4..d9b0cf47 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -114,6 +114,7 @@ set(LIBRARYV3 ./libraries/v3/floodfillv2togradient.cpp ./libraries/v3/floodfillv2sampler.cpp # Phase 1 — Filters / Color + ./libraries/v3/blendv3.cpp ./libraries/v3/edgedetect.cpp ./libraries/v3/highpass.cpp ./libraries/v3/emboss.cpp diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index b65cbada..bd189be8 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -233,6 +233,7 @@ Library* createLibraryV3() // ----------------------------------------------------------------------- // Phase 1 — Filters / Color // ----------------------------------------------------------------------- + lib->addNode("blendv3", "Blend", ":nodes/blend.png"); lib->addNode("edgedetect", "Edge Detect", ":nodes/bevel.png"); lib->addNode("highpass", "Highpass", ":nodes/blurv2.png"); diff --git a/src/texturelab/libraries/libv3.h b/src/texturelab/libraries/libv3.h index 80f2848a..99cb2e45 100644 --- a/src/texturelab/libraries/libv3.h +++ b/src/texturelab/libraries/libv3.h @@ -114,6 +114,11 @@ class HeightBlendNode : public TextureNode { void init() override; }; +class BlendV3Node : public TextureNode { +public: + void init() override; +}; + class MakeItTileNode : public TextureNode { public: void init() override; diff --git a/src/texturelab/libraries/v3/blendv3.cpp b/src/texturelab/libraries/v3/blendv3.cpp new file mode 100644 index 00000000..b37025bd --- /dev/null +++ b/src/texturelab/libraries/v3/blendv3.cpp @@ -0,0 +1,120 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void BlendV3Node::init() +{ + this->title = "Blend"; + + this->addInput("colorA"); // top / foreground + this->addInput("colorB"); // bottom / background + this->addInput("opacity"); // optional mask + + this->addEnumProp("type", "Type", { + "Normal", + "Multiply", + "Screen", + "Overlay", + "Soft Light", + "Hard Light", + "Color Dodge", + "Color Burn", + "Linear Dodge", + "Linear Burn", + "Difference", + "Exclusion", + "Add", + "Subtract", + "Divide", + "Max", + "Min", + }); + this->addFloatProp("opacity", "Opacity", 1.0, 0.0, 1.0, 0.01); + + this->setShaderSource(R""""( + vec3 blend_screen(vec3 a, vec3 b) { + return 1.0 - (1.0 - a) * (1.0 - b); + } + + // Standard overlay: base is bottom layer, blend is top layer. + // if base < 0.5: 2*base*blend, else 1 - 2*(1-base)*(1-blend) + vec3 blend_overlay(vec3 base, vec3 blend) { + return mix( + 2.0 * base * blend, + 1.0 - 2.0 * (1.0 - base) * (1.0 - blend), + step(vec3(0.5), base) + ); + } + + // Pegtop / W3C two-case approximation for soft light. + // base = bottom, blend = top (light source). + vec3 blend_soft_light(vec3 base, vec3 blend) { + return mix( + 2.0 * base * blend + base * base * (1.0 - 2.0 * blend), + 2.0 * base * (1.0 - blend) + sqrt(base) * (2.0 * blend - 1.0), + step(vec3(0.5), blend) + ); + } + + // Hard light = overlay with layers swapped. + vec3 blend_hard_light(vec3 base, vec3 blend) { + return blend_overlay(blend, base); + } + + vec3 blend_color_dodge(vec3 base, vec3 blend) { + return clamp(base / max(1.0 - blend, vec3(1e-4)), 0.0, 1.0); + } + + vec3 blend_color_burn(vec3 base, vec3 blend) { + return clamp(1.0 - (1.0 - base) / max(blend, vec3(1e-4)), 0.0, 1.0); + } + + vec4 process(vec2 uv) + { + float finalOpacity = prop_opacity; + if (opacity_connected) + finalOpacity *= texture(opacity, uv).r; + + vec4 colA = texture(colorA, uv); // top / foreground + vec4 colB = texture(colorB, uv); // bottom / background + vec3 result = colB.rgb; + + if (prop_type == 0) // Normal + result = colA.rgb; + else if (prop_type == 1) // Multiply + result = colA.rgb * colB.rgb; + else if (prop_type == 2) // Screen + result = blend_screen(colA.rgb, colB.rgb); + else if (prop_type == 3) // Overlay + result = blend_overlay(colB.rgb, colA.rgb); + else if (prop_type == 4) // Soft Light + result = blend_soft_light(colB.rgb, colA.rgb); + else if (prop_type == 5) // Hard Light + result = blend_hard_light(colB.rgb, colA.rgb); + else if (prop_type == 6) // Color Dodge + result = blend_color_dodge(colB.rgb, colA.rgb); + else if (prop_type == 7) // Color Burn + result = blend_color_burn(colB.rgb, colA.rgb); + else if (prop_type == 8) // Linear Dodge (Add, clamped) + result = clamp(colA.rgb + colB.rgb, 0.0, 1.0); + else if (prop_type == 9) // Linear Burn + result = clamp(colA.rgb + colB.rgb - 1.0, 0.0, 1.0); + else if (prop_type == 10) // Difference + result = abs(colA.rgb - colB.rgb); + else if (prop_type == 11) // Exclusion + result = colA.rgb + colB.rgb - 2.0 * colA.rgb * colB.rgb; + else if (prop_type == 12) // Add (unclamped) + result = colA.rgb + colB.rgb; + else if (prop_type == 13) // Subtract + result = colB.rgb - colA.rgb; + else if (prop_type == 14) // Divide + result = clamp(colB.rgb / max(colA.rgb, vec3(1e-4)), 0.0, 1.0); + else if (prop_type == 15) // Max + result = max(colA.rgb, colB.rgb); + else // Min + result = min(colA.rgb, colB.rgb); + + return vec4(mix(colB.rgb, result, finalOpacity), colB.a); + } + )""""); +} From 1e0374972a32799e683c36cc65708c62acfa5321 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 31 May 2026 22:53:00 -0500 Subject: [PATCH 070/164] fix blend node filter order --- src/texturelab/libraries/library.cpp | 3 +- src/texturelab/libraries/v3/blendv3.cpp | 98 +++++++++++++------------ 2 files changed, 54 insertions(+), 47 deletions(-) diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index bd189be8..8cb7047c 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -200,6 +200,7 @@ Library* createLibraryV3() lib->items.remove("floodfilltorandomintensity"); lib->items.remove("bevel"); lib->items.remove("perlin3d"); + lib->items.remove("blend"); // V3 NODES lib->addNode("bevelv2", "Bevel V2", ":nodes/bevel.png"); @@ -233,7 +234,7 @@ Library* createLibraryV3() // ----------------------------------------------------------------------- // Phase 1 — Filters / Color // ----------------------------------------------------------------------- - lib->addNode("blendv3", "Blend", ":nodes/blend.png"); + lib->addNode("blend", "Blend", ":nodes/blend.png"); lib->addNode("edgedetect", "Edge Detect", ":nodes/bevel.png"); lib->addNode("highpass", "Highpass", ":nodes/blurv2.png"); diff --git a/src/texturelab/libraries/v3/blendv3.cpp b/src/texturelab/libraries/v3/blendv3.cpp index b37025bd..c1537f9d 100644 --- a/src/texturelab/libraries/v3/blendv3.cpp +++ b/src/texturelab/libraries/v3/blendv3.cpp @@ -6,29 +6,33 @@ void BlendV3Node::init() { this->title = "Blend"; - this->addInput("colorA"); // top / foreground - this->addInput("colorB"); // bottom / background - this->addInput("opacity"); // optional mask + this->addInput("colorA"); // top / foreground + this->addInput("colorB"); // bottom / background + this->addInput("opacity"); // optional mask - this->addEnumProp("type", "Type", { - "Normal", - "Multiply", - "Screen", - "Overlay", - "Soft Light", - "Hard Light", - "Color Dodge", - "Color Burn", - "Linear Dodge", - "Linear Burn", - "Difference", - "Exclusion", - "Add", - "Subtract", - "Divide", - "Max", - "Min", - }); + // Indices 0-8 match the v1 blend node for consistency. + // New modes are appended from index 9 onward. + this->addEnumProp("type", "Type", + { + "Multiply", // 0 + "Add", // 1 + "Subtract", // 2 + "Divide", // 3 + "Max", // 4 + "Min", // 5 + "Switch", // 6 (shows colorA — same as v1) + "Overlay", // 7 (fixed: correct 2× factor) + "Screen", // 8 + "Soft Light", // 9 + "Hard Light", // 10 + "Color Dodge", // 11 + "Color Burn", // 12 + "Linear Dodge", // 13 + "Linear Burn", // 14 + "Difference", // 15 + "Exclusion", // 16 + "Normal", // 17 + }); this->addFloatProp("opacity", "Opacity", 1.0, 0.0, 1.0, 0.01); this->setShaderSource(R""""( @@ -79,40 +83,42 @@ void BlendV3Node::init() vec4 colB = texture(colorB, uv); // bottom / background vec3 result = colB.rgb; - if (prop_type == 0) // Normal - result = colA.rgb; - else if (prop_type == 1) // Multiply + if (prop_type == 0) // Multiply result = colA.rgb * colB.rgb; - else if (prop_type == 2) // Screen - result = blend_screen(colA.rgb, colB.rgb); - else if (prop_type == 3) // Overlay + else if (prop_type == 1) // Add + result = colA.rgb + colB.rgb; + else if (prop_type == 2) // Subtract + result = colB.rgb - colA.rgb; + else if (prop_type == 3) // Divide + result = colB.rgb / max(colA.rgb, vec3(1e-4)); + else if (prop_type == 4) // Max + result = max(colA.rgb, colB.rgb); + else if (prop_type == 5) // Min + result = min(colA.rgb, colB.rgb); + else if (prop_type == 6) // Switch (show colorA) + result = colA.rgb; + else if (prop_type == 7) // Overlay (fixed) result = blend_overlay(colB.rgb, colA.rgb); - else if (prop_type == 4) // Soft Light + else if (prop_type == 8) // Screen + result = blend_screen(colA.rgb, colB.rgb); + else if (prop_type == 9) // Soft Light result = blend_soft_light(colB.rgb, colA.rgb); - else if (prop_type == 5) // Hard Light + else if (prop_type == 10) // Hard Light result = blend_hard_light(colB.rgb, colA.rgb); - else if (prop_type == 6) // Color Dodge + else if (prop_type == 11) // Color Dodge result = blend_color_dodge(colB.rgb, colA.rgb); - else if (prop_type == 7) // Color Burn + else if (prop_type == 12) // Color Burn result = blend_color_burn(colB.rgb, colA.rgb); - else if (prop_type == 8) // Linear Dodge (Add, clamped) + else if (prop_type == 13) // Linear Dodge (clamped Add) result = clamp(colA.rgb + colB.rgb, 0.0, 1.0); - else if (prop_type == 9) // Linear Burn + else if (prop_type == 14) // Linear Burn result = clamp(colA.rgb + colB.rgb - 1.0, 0.0, 1.0); - else if (prop_type == 10) // Difference + else if (prop_type == 15) // Difference result = abs(colA.rgb - colB.rgb); - else if (prop_type == 11) // Exclusion + else if (prop_type == 16) // Exclusion result = colA.rgb + colB.rgb - 2.0 * colA.rgb * colB.rgb; - else if (prop_type == 12) // Add (unclamped) - result = colA.rgb + colB.rgb; - else if (prop_type == 13) // Subtract - result = colB.rgb - colA.rgb; - else if (prop_type == 14) // Divide - result = clamp(colB.rgb / max(colA.rgb, vec3(1e-4)), 0.0, 1.0); - else if (prop_type == 15) // Max - result = max(colA.rgb, colB.rgb); - else // Min - result = min(colA.rgb, colB.rgb); + else // Normal (17) + result = colA.rgb; return vec4(mix(colB.rgb, result, finalOpacity), colB.a); } From f9729f09eed3654e364e48f63e806b982c5fb3f9 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 31 May 2026 23:34:16 -0500 Subject: [PATCH 071/164] load and display alphas channels --- src/nodegraph/graph/scene.cpp | 18 +++++++++++++++++- .../widgets/properties/propwidgets.cpp | 2 ++ src/texturelab/widgets/view2dwidget.cpp | 8 +++++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/nodegraph/graph/scene.cpp b/src/nodegraph/graph/scene.cpp index 1827c265..47f4ed0a 100644 --- a/src/nodegraph/graph/scene.cpp +++ b/src/nodegraph/graph/scene.cpp @@ -51,7 +51,13 @@ void Node::initializeGL() out vec4 fragColor; uniform sampler2D textureSampler; void main() { - fragColor = texture(textureSampler, vTexCoord); + // 8px checkerboard in screen space + vec2 tile = floor(gl_FragCoord.xy / 8.0); + float checker = mod(tile.x + tile.y, 2.0); + vec3 bg = mix(vec3(0.753), vec3(0.502), checker); + + vec4 texColor = texture(textureSampler, vTexCoord); + fragColor = vec4(mix(bg, texColor.rgb, texColor.a), 1.0); } )"; @@ -469,6 +475,16 @@ void Node::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, painter->fillPath(bgPath, QBrush(QColor(10, 10, 10, 255))); if (!thumbnail.isNull()) { + // Checkerboard background for alpha-transparent thumbnails + static QPixmap checkerTile; + if (checkerTile.isNull()) { + checkerTile = QPixmap(16, 16); + checkerTile.fill(QColor(0xC0, 0xC0, 0xC0)); + QPainter cp(&checkerTile); + cp.fillRect(0, 0, 8, 8, QColor(0x80, 0x80, 0x80)); + cp.fillRect(8, 8, 8, 8, QColor(0x80, 0x80, 0x80)); + } + painter->fillRect(QRect(0, 0, nodeWidth, nodeHeight), QBrush(checkerTile)); painter->drawPixmap(QRect(0, 0, nodeWidth, nodeHeight), thumbnail); } diff --git a/src/texturelab/widgets/properties/propwidgets.cpp b/src/texturelab/widgets/properties/propwidgets.cpp index 16284d98..7e82eca3 100644 --- a/src/texturelab/widgets/properties/propwidgets.cpp +++ b/src/texturelab/widgets/properties/propwidgets.cpp @@ -523,6 +523,8 @@ bool ImagePropWidget::eventFilter(QObject* obj, QEvent* event) filePath = fileName; QImage image(fileName); if (!image.isNull()) { + if (image.format() != QImage::Format_RGBA8888) + image = image.convertToFormat(QImage::Format_RGBA8888); if (prop) { prop->value = image; updateImagePreview(); diff --git a/src/texturelab/widgets/view2dwidget.cpp b/src/texturelab/widgets/view2dwidget.cpp index db05c85a..afa4a07b 100644 --- a/src/texturelab/widgets/view2dwidget.cpp +++ b/src/texturelab/widgets/view2dwidget.cpp @@ -447,7 +447,13 @@ void NodePreviewGraphicsItem::initializeGL() out vec4 fragColor; uniform sampler2D textureSampler; void main() { - fragColor = texture(textureSampler, vTexCoord); + // 16px checkerboard in screen space + vec2 tile = floor(gl_FragCoord.xy / 16.0); + float checker = mod(tile.x + tile.y, 2.0); + vec3 bg = mix(vec3(0.753), vec3(0.502), checker); + + vec4 texColor = texture(textureSampler, vTexCoord); + fragColor = vec4(mix(bg, texColor.rgb, texColor.a), 1.0); } )"; From 85c7992429439f9708e5da0380f89fbe62df7a90 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Mon, 1 Jun 2026 00:08:27 -0500 Subject: [PATCH 072/164] simplify overlay --- src/texturelab/libraries/v3/blendv3.cpp | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/texturelab/libraries/v3/blendv3.cpp b/src/texturelab/libraries/v3/blendv3.cpp index c1537f9d..416e9cec 100644 --- a/src/texturelab/libraries/v3/blendv3.cpp +++ b/src/texturelab/libraries/v3/blendv3.cpp @@ -42,21 +42,27 @@ void BlendV3Node::init() // Standard overlay: base is bottom layer, blend is top layer. // if base < 0.5: 2*base*blend, else 1 - 2*(1-base)*(1-blend) + // Uses explicit per-channel conditionals to avoid evaluating both + // branches simultaneously, which can produce NaN/Inf with HDR inputs. vec3 blend_overlay(vec3 base, vec3 blend) { - return mix( - 2.0 * base * blend, - 1.0 - 2.0 * (1.0 - base) * (1.0 - blend), - step(vec3(0.5), base) + vec3 dark = 2.0 * base * blend; + vec3 light = 1.0 - 2.0 * (1.0 - base) * (1.0 - blend); + return vec3( + base.r < 0.5 ? dark.r : light.r, + base.g < 0.5 ? dark.g : light.g, + base.b < 0.5 ? dark.b : light.b ); } // Pegtop / W3C two-case approximation for soft light. // base = bottom, blend = top (light source). vec3 blend_soft_light(vec3 base, vec3 blend) { - return mix( - 2.0 * base * blend + base * base * (1.0 - 2.0 * blend), - 2.0 * base * (1.0 - blend) + sqrt(base) * (2.0 * blend - 1.0), - step(vec3(0.5), blend) + vec3 dark = 2.0 * base * blend + base * base * (1.0 - 2.0 * blend); + vec3 light = 2.0 * base * (1.0 - blend) + sqrt(clamp(base, 0.0, 1.0)) * (2.0 * blend - 1.0); + return vec3( + blend.r < 0.5 ? dark.r : light.r, + blend.g < 0.5 ? dark.g : light.g, + blend.b < 0.5 ? dark.b : light.b ); } From 329cde63860cda1022d320e334efba17576c5a02 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Mon, 1 Jun 2026 00:22:28 -0500 Subject: [PATCH 073/164] fix blending --- src/texturelab/libraries/v3/blendv3.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/texturelab/libraries/v3/blendv3.cpp b/src/texturelab/libraries/v3/blendv3.cpp index 416e9cec..e9e5cc68 100644 --- a/src/texturelab/libraries/v3/blendv3.cpp +++ b/src/texturelab/libraries/v3/blendv3.cpp @@ -2,6 +2,10 @@ #include "../../props.h" #include "../libv3.h" +// reference: +// https://ssp.impulsetrain.com/porterduff.html +// https://github.com/dpt/Porter-Duff/blob/master/Porter-Duff.md + void BlendV3Node::init() { this->title = "Blend"; @@ -126,7 +130,11 @@ void BlendV3Node::init() else // Normal (17) result = colA.rgb; - return vec4(mix(colB.rgb, result, finalOpacity), colB.a); + // Factor in the foreground's own alpha so transparent regions + // of the overlay let the background show through. + float blendFactor = finalOpacity * colA.a; + float outAlpha = blendFactor + colB.a * (1.0 - blendFactor); + return vec4(mix(colB.rgb, result, blendFactor), outAlpha); } )""""); } From 34bd0d16aba4c64042135ec4f0820c3d05588711 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 6 Jun 2026 14:35:58 -0500 Subject: [PATCH 074/164] and x and y for solid cell nodes --- src/texturelab/CMakeLists.txt | 3 + src/texturelab/libraries/library.cpp | 8 ++ src/texturelab/libraries/libv3.h | 15 ++++ src/texturelab/libraries/v3/cellv3.cpp | 47 +++++++++++ src/texturelab/libraries/v3/linecellv3.cpp | 86 +++++++++++++++++++++ src/texturelab/libraries/v3/solidcellv3.cpp | 56 ++++++++++++++ 6 files changed, 215 insertions(+) create mode 100644 src/texturelab/libraries/v3/cellv3.cpp create mode 100644 src/texturelab/libraries/v3/linecellv3.cpp create mode 100644 src/texturelab/libraries/v3/solidcellv3.cpp diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index d9b0cf47..8525d317 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -130,6 +130,9 @@ set(LIBRARYV3 ./libraries/v3/fbmdomainwarp.cpp ./libraries/v3/perlinnoise.cpp ./libraries/v3/perlinnoise3d.cpp + ./libraries/v3/cellv3.cpp + ./libraries/v3/linecellv3.cpp + ./libraries/v3/solidcellv3.cpp # Phase 3 — Multi-pass ./libraries/v3/blurhq.cpp ./libraries/v3/distancetransform.cpp diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index 8cb7047c..1e79f74e 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -201,6 +201,9 @@ Library* createLibraryV3() lib->items.remove("bevel"); lib->items.remove("perlin3d"); lib->items.remove("blend"); + lib->items.remove("cell"); + lib->items.remove("linecell"); + lib->items.remove("solidcell"); // V3 NODES lib->addNode("bevelv2", "Bevel V2", ":nodes/bevel.png"); @@ -263,6 +266,11 @@ Library* createLibraryV3() ":nodes/fractalnoise.png"); lib->addNode("perlinnoise3d", "Perlin Noise 3D", ":nodes/fractalnoise.png"); + lib->addNode("cell", "Cell V3", ":nodes/cell.png"); + lib->addNode("linecell", "Line Cell V3", + ":nodes/linecell.png"); + lib->addNode("solidcell", "Solid Cell V3", + ":nodes/solidcell.png"); // ----------------------------------------------------------------------- // Phase 3 — Multi-pass diff --git a/src/texturelab/libraries/libv3.h b/src/texturelab/libraries/libv3.h index 99cb2e45..db5eed95 100644 --- a/src/texturelab/libraries/libv3.h +++ b/src/texturelab/libraries/libv3.h @@ -197,3 +197,18 @@ class FloodFillV2SamplerNode : public TextureNode { public: virtual void init() override; }; + +class CellV3Node : public TextureNode { +public: + void init() override; +}; + +class LineCellV3Node : public TextureNode { +public: + void init() override; +}; + +class SolidCellV3Node : public TextureNode { +public: + void init() override; +}; diff --git a/src/texturelab/libraries/v3/cellv3.cpp b/src/texturelab/libraries/v3/cellv3.cpp new file mode 100644 index 00000000..61efd978 --- /dev/null +++ b/src/texturelab/libraries/v3/cellv3.cpp @@ -0,0 +1,47 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Voronoi F1 cell node with independent X/Y scale. +// Seamless: grid sizes are rounded to integers so mod()-wrapped cell hashes +// produce identical values at opposite UV boundaries. +void CellV3Node::init() +{ + this->title = "Cell"; + + this->addFloatProp("scale", "Scale", 30.0, 1.0, 256.0, 1.0); + this->addFloatProp("scaleX", "Scale X", 1.0, 0.1, 10.0, 0.1); + this->addFloatProp("scaleY", "Scale Y", 1.0, 0.1, 10.0, 0.1); + this->addBoolProp ("invert", "Invert", false); + this->addFloatProp("entropy", "Order", 0.0, 0.0, 1.0, 0.01); + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 2.0, 0.01); + + this->setShaderSource(R""""( + vec4 process(vec2 uv) + { + float gsX = max(1.0, floor(prop_scale * prop_scaleX + 0.5)); + float gsY = max(1.0, floor(prop_scale * prop_scaleY + 0.5)); + + vec2 scaledUV = vec2(uv.x * gsX, uv.y * gsY); + vec2 i_st = floor(scaledUV); + vec2 f_st = fract(scaledUV); + + vec2 seedOff = vec2(_seed * 0.173, _seed * 0.251); + float m_dist = 1.0; + + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + vec2 neighbor = vec2(float(x), float(y)); + vec2 wrapped = mod(i_st + neighbor, vec2(gsX, gsY)); + vec2 point = hash22(wrapped + seedOff); + point = mix(point, vec2(0.5), prop_entropy); + vec2 diff = neighbor + point - f_st; + m_dist = min(m_dist, length(diff)); + } + } + + if (prop_invert) m_dist = 1.0 - m_dist; + return vec4(vec3(m_dist) * prop_intensity, 1.0); + } + )""""); +} diff --git a/src/texturelab/libraries/v3/linecellv3.cpp b/src/texturelab/libraries/v3/linecellv3.cpp new file mode 100644 index 00000000..236bfe03 --- /dev/null +++ b/src/texturelab/libraries/v3/linecellv3.cpp @@ -0,0 +1,86 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Voronoi edge (line-cell) node with independent X/Y scale. +// Renders the borders between Voronoi cells as bright lines. +// Seamless: integer-rounded grid sizes with mod()-wrapped cell hashes. +// Reference: https://iquilezles.org/articles/voronoilines/ +void LineCellV3Node::init() +{ + this->title = "Line Cell"; + + this->addFloatProp("scale", "Scale", 30.0, 1.0, 256.0, 1.0); + this->addFloatProp("scaleX", "Scale X", 1.0, 0.1, 10.0, 0.1); + this->addFloatProp("scaleY", "Scale Y", 1.0, 0.1, 10.0, 0.1); + this->addBoolProp ("invert", "Invert", false); + this->addFloatProp("entropy", "Order", 0.0, 0.0, 1.0, 0.01); + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 2.0, 0.01); + this->addFloatProp("thickness", "Line Thickness", 0.05, 0.0, 0.3, 0.005); + + this->setShaderSource(R""""( + // Returns (edgeDist, nearestDiff.xy). + // edgeDist = perpendicular distance to the nearest Voronoi edge. + vec3 voronoiEdge(vec2 scaledUV, float gsX, float gsY) + { + vec2 i_st = floor(scaledUV); + vec2 f_st = fract(scaledUV); + vec2 seedOff = vec2(_seed * 0.173, _seed * 0.251); + + // Pass 1: find nearest cell. + float md = 1e9; + vec2 mg = vec2(0.0); + vec2 mr = vec2(0.0); + + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + vec2 neighbor = vec2(float(x), float(y)); + vec2 wrapped = mod(i_st + neighbor, vec2(gsX, gsY)); + vec2 point = hash22(wrapped + seedOff); + point = mix(point, vec2(0.5), prop_entropy); + vec2 diff = neighbor + point - f_st; + float dist = length(diff); + if (dist < md) { + md = dist; + mr = diff; + mg = neighbor; + } + } + } + + // Pass 2: distance to bisector of nearest and second-nearest cells. + float edgeDist = 1e9; + for (int j = -2; j <= 2; j++) { + for (int i = -2; i <= 2; i++) { + vec2 neighbor = mg + vec2(float(i), float(j)); + vec2 wrapped = mod(i_st + neighbor, vec2(gsX, gsY)); + vec2 point = hash22(wrapped + seedOff); + point = mix(point, vec2(0.5), prop_entropy); + vec2 diff = neighbor + point - f_st; + if (dot(mr - diff, mr - diff) > 0.00001) + edgeDist = min(edgeDist, + dot(0.5 * (mr + diff), normalize(diff - mr))); + } + } + + return vec3(edgeDist, mr); + } + + vec4 process(vec2 uv) + { + float gsX = max(1.0, floor(prop_scale * prop_scaleX + 0.5)); + float gsY = max(1.0, floor(prop_scale * prop_scaleY + 0.5)); + + vec2 scaledUV = vec2(uv.x * gsX, uv.y * gsY); + vec3 c = voronoiEdge(scaledUV, gsX, gsY); + + // c.x = 0 at edge centre, grows away from it. + // Smooth bright line that fades to black past `thickness`. + float edge = 1.0 - smoothstep(0.0, prop_thickness, c.x); + vec3 color = vec3(edge); + + if (prop_invert) color = 1.0 - color; + return vec4(color * prop_intensity, 1.0); + } + )""""); +} diff --git a/src/texturelab/libraries/v3/solidcellv3.cpp b/src/texturelab/libraries/v3/solidcellv3.cpp new file mode 100644 index 00000000..6fdf811b --- /dev/null +++ b/src/texturelab/libraries/v3/solidcellv3.cpp @@ -0,0 +1,56 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Voronoi solid-fill cell node with independent X/Y scale. +// Each cell gets a unique random grayscale value derived from its wrapped index, +// so opposite UV boundaries share identical cell hashes — seamless tiling. +void SolidCellV3Node::init() +{ + this->title = "Solid Cell"; + + this->addFloatProp("scale", "Scale", 30.0, 1.0, 256.0, 1.0); + this->addFloatProp("scaleX", "Scale X", 1.0, 0.1, 10.0, 0.1); + this->addFloatProp("scaleY", "Scale Y", 1.0, 0.1, 10.0, 0.1); + this->addBoolProp ("invert", "Invert", false); + this->addFloatProp("entropy", "Order", 0.0, 0.0, 1.0, 0.01); + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 2.0, 0.01); + + this->setShaderSource(R""""( + vec4 process(vec2 uv) + { + float gsX = max(1.0, floor(prop_scale * prop_scaleX + 0.5)); + float gsY = max(1.0, floor(prop_scale * prop_scaleY + 0.5)); + + vec2 scaledUV = vec2(uv.x * gsX, uv.y * gsY); + vec2 i_st = floor(scaledUV); + vec2 f_st = fract(scaledUV); + + vec2 seedOff = vec2(_seed * 0.173, _seed * 0.251); + float m_dist = 1e9; + vec2 closestWrapped = vec2(0.0); + + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + vec2 neighbor = vec2(float(x), float(y)); + vec2 wrapped = mod(i_st + neighbor, vec2(gsX, gsY)); + vec2 point = hash22(wrapped + seedOff); + point = mix(point, vec2(0.5), prop_entropy); + vec2 diff = neighbor + point - f_st; + float dist = length(diff); + if (dist < m_dist) { + m_dist = dist; + closestWrapped = wrapped; + } + } + } + + // Use a second hash to generate per-cell color so it is independent + // of the cell-point position hash used above. + float cellColor = hash22(closestWrapped + vec2(_seed * 0.391, _seed * 0.617)).x; + vec3 color = vec3(cellColor); + if (prop_invert) color = 1.0 - color; + return vec4(color * prop_intensity, 1.0); + } + )""""); +} From 3028b909b1f73d91a0b61e92340dcc53754061f4 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 6 Jun 2026 15:51:21 -0500 Subject: [PATCH 075/164] fix prop widget update flow --- .../widgets/properties/propwidgets.cpp | 135 +++++++++++------- .../widgets/properties/propwidgets.h | 2 + 2 files changed, 88 insertions(+), 49 deletions(-) diff --git a/src/texturelab/widgets/properties/propwidgets.cpp b/src/texturelab/widgets/properties/propwidgets.cpp index 7e82eca3..0a095961 100644 --- a/src/texturelab/widgets/properties/propwidgets.cpp +++ b/src/texturelab/widgets/properties/propwidgets.cpp @@ -19,30 +19,39 @@ #include #include #include +#include +#include const int SLIDER_MAX = 1000; +class NoWheelSlider : public QSlider { +public: + using QSlider::QSlider; + void wheelEvent(QWheelEvent* event) override { event->ignore(); } +}; + // FLOAT PROP WIDGET // https://stackoverflow.com/a/19007951 FloatPropWidget::FloatPropWidget() { prop = nullptr; + updating = false; auto vlayout = new QVBoxLayout(this); this->setLayout(vlayout); - // label label = new QLabel(this); label->setText(""); vlayout->addWidget(label); - // slider - slider = new QSlider(Qt::Horizontal, this); + slider = new NoWheelSlider(Qt::Horizontal, this); slider->setMinimum(0); slider->setMaximum(SLIDER_MAX); slider->setSingleStep(1); spinbox = new QDoubleSpinBox(this); + spinbox->setMaximum(std::numeric_limits::max()); + spinbox->setFixedWidth(60); auto hbox = new QHBoxLayout(); hbox->addWidget(slider); @@ -53,43 +62,54 @@ FloatPropWidget::FloatPropWidget() this->setFixedHeight(80); connect(slider, &QSlider::valueChanged, [=](int val) { - auto percent = val / (float)SLIDER_MAX; - if (prop) { - auto range = prop->maxValue - prop->minValue; - auto finalValue = prop->minValue + range * percent; - spinbox->setValue(finalValue); - - emit valueChanged(finalValue); - } + if (updating || !prop) + return; + updating = true; + auto range = prop->maxValue - prop->minValue; + auto finalValue = prop->minValue + range * (val / (double)SLIDER_MAX); + spinbox->setValue(finalValue); + updating = false; + emit valueChanged(finalValue); }); connect(spinbox, &QDoubleSpinBox::valueChanged, [=](double val) { - if (prop) { - auto range = prop->maxValue - prop->minValue; - auto finalValue = ((val - prop->minValue) / range) * SLIDER_MAX; - - slider->setValue(finalValue); - - emit valueChanged(val); - } + if (updating || !prop) + return; + updating = true; + auto range = prop->maxValue - prop->minValue; + int sliderVal = + (range > 0) + ? qBound(0, (int)((val - prop->minValue) / range * SLIDER_MAX), + SLIDER_MAX) + : 0; + slider->setValue(sliderVal); + updating = false; + emit valueChanged(val); }); } void FloatPropWidget::setProp(FloatProp* prop) { + this->prop = prop; + updating = true; + label->setText(prop->displayName); spinbox->setMinimum(prop->minValue); - spinbox->setMaximum(prop->maxValue); + spinbox->setMaximum(std::numeric_limits::max()); spinbox->setSingleStep(prop->step); spinbox->setValue(prop->value); auto range = prop->maxValue - prop->minValue; - auto finalValue = ((prop->value - prop->minValue) / range) * SLIDER_MAX; - - slider->setValue(finalValue); - - this->prop = prop; + int sliderVal = + (range > 0) + ? qBound(0, + (int)((prop->value - prop->minValue) / range * SLIDER_MAX), + SLIDER_MAX) + : 0; + slider->setValue(sliderVal); + + updating = false; } // INT PROP WIDGET @@ -97,22 +117,23 @@ void FloatPropWidget::setProp(FloatProp* prop) IntPropWidget::IntPropWidget() { prop = nullptr; + updating = false; auto vlayout = new QVBoxLayout(this); this->setLayout(vlayout); - // label label = new QLabel(this); label->setText(""); vlayout->addWidget(label); - // slider - slider = new QSlider(Qt::Horizontal, this); + slider = new NoWheelSlider(Qt::Horizontal, this); slider->setMinimum(0); slider->setMaximum(SLIDER_MAX); slider->setSingleStep(1); spinbox = new QSpinBox(this); + spinbox->setMaximum(INT_MAX); + spinbox->setFixedWidth(60); auto hbox = new QHBoxLayout(); hbox->addWidget(slider); @@ -123,38 +144,54 @@ IntPropWidget::IntPropWidget() this->setFixedHeight(80); connect(slider, &QSlider::valueChanged, [=](int val) { - auto percent = val / (float)SLIDER_MAX; - if (prop) { - spinbox->setValue(val); - - emit valueChanged(val); - } + if (updating || !prop) + return; + updating = true; + auto range = prop->maxValue - prop->minValue; + long finalValue = + prop->minValue + (long)qRound(range * (val / (double)SLIDER_MAX)); + spinbox->setValue((int)finalValue); + updating = false; + emit valueChanged(finalValue); }); connect(spinbox, &QSpinBox::valueChanged, [=](int val) { - if (prop) { - slider->setValue(val); - - emit valueChanged(val); - } + if (updating || !prop) + return; + updating = true; + auto range = prop->maxValue - prop->minValue; + int sliderVal = (range > 0) ? qBound(0, + (int)((val - prop->minValue) / + (double)range * SLIDER_MAX), + SLIDER_MAX) + : 0; + slider->setValue(sliderVal); + updating = false; + emit valueChanged((long)val); }); } void IntPropWidget::setProp(IntProp* prop) { - label->setText(prop->displayName); + this->prop = prop; + updating = true; - spinbox->setMinimum(prop->minValue); - spinbox->setMaximum(prop->maxValue); - spinbox->setSingleStep(prop->step); - spinbox->setValue(prop->value); + label->setText(prop->displayName); - slider->setValue(prop->value); - slider->setMinimum(prop->minValue); - slider->setMaximum(prop->maxValue); - slider->setSingleStep(prop->step); + spinbox->setMinimum((int)prop->minValue); + spinbox->setMaximum(INT_MAX); + spinbox->setSingleStep((int)prop->step); + spinbox->setValue((int)prop->value); - this->prop = prop; + auto range = prop->maxValue - prop->minValue; + int sliderVal = (range > 0) ? qBound(0, + (int)((prop->value - prop->minValue) / + (double)range * SLIDER_MAX), + SLIDER_MAX) + : 0; + slider->setValue(sliderVal); + + updating = false; } // ENUM PROP WIDGET diff --git a/src/texturelab/widgets/properties/propwidgets.h b/src/texturelab/widgets/properties/propwidgets.h index 14091de6..661262a0 100644 --- a/src/texturelab/widgets/properties/propwidgets.h +++ b/src/texturelab/widgets/properties/propwidgets.h @@ -29,6 +29,7 @@ class FloatPropWidget : public QWidget { QDoubleSpinBox* spinbox; FloatProp* prop; + bool updating; public: FloatPropWidget(); @@ -45,6 +46,7 @@ class IntPropWidget : public QWidget { QSpinBox* spinbox; IntProp* prop; + bool updating; public: IntPropWidget(); From 49332e73b434f932144d1c352a587b24c2078ee7 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 6 Jun 2026 16:10:30 -0500 Subject: [PATCH 076/164] fix preview screen panning --- src/texturelab/widgets/view2dwidget.cpp | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/texturelab/widgets/view2dwidget.cpp b/src/texturelab/widgets/view2dwidget.cpp index afa4a07b..e1bf7c14 100644 --- a/src/texturelab/widgets/view2dwidget.cpp +++ b/src/texturelab/widgets/view2dwidget.cpp @@ -319,20 +319,22 @@ void View2DGraph::scaleDown() // void View2DGraph::keyReleaseEvent(QKeyEvent* event){}; void View2DGraph::mousePressEvent(QMouseEvent* event) { - if (event->button() == Qt::MiddleButton && - scene()->mouseGrabberItem() == nullptr) { - _clickPos = mapToScene(event->pos()); + if (event->button() == Qt::MiddleButton) { + _clickPos = event->pos(); setDragMode(QGraphicsView::NoDrag); + return; } QGraphicsView::mousePressEvent(event); } void View2DGraph::mouseMoveEvent(QMouseEvent* event) { - - if (event->buttons() == Qt::MiddleButton) { - QPointF difference = _clickPos - mapToScene(event->pos()); - setSceneRect(sceneRect().translated(difference.x(), difference.y())); + if (event->buttons() & Qt::MiddleButton) { + QPointF delta = event->pos() - _clickPos; + qreal s = transform().m11(); + setSceneRect(sceneRect().translated(-delta.x() / s, -delta.y() / s)); + _clickPos = event->pos(); + return; } QGraphicsView::mouseMoveEvent(event); } @@ -340,6 +342,7 @@ void View2DGraph::mouseMoveEvent(QMouseEvent* event) void View2DGraph::mouseReleaseEvent(QMouseEvent* event) { if (event->button() == Qt::MiddleButton) { + setDragMode(QGraphicsView::ScrollHandDrag); } QGraphicsView::mouseReleaseEvent(event); } From 8a943b3735b6d7a3383f52d480209bbcb5069efd Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 6 Jun 2026 16:36:02 -0500 Subject: [PATCH 077/164] add props for frames and comments --- src/nodegraph/nodegraph.cpp | 17 +++++ src/nodegraph/nodegraph.h | 4 ++ src/texturelab/mainwindow.cpp | 32 +++++++++ src/texturelab/widgets/graphwidget.cpp | 70 +++++++++++++++++++ src/texturelab/widgets/graphwidget.h | 9 +++ .../widgets/properties/propertieswidget.cpp | 70 ++++++++++++++++++- .../widgets/properties/propertieswidget.h | 10 +++ 7 files changed, 210 insertions(+), 2 deletions(-) diff --git a/src/nodegraph/nodegraph.cpp b/src/nodegraph/nodegraph.cpp index b488f241..2cdc0b59 100644 --- a/src/nodegraph/nodegraph.cpp +++ b/src/nodegraph/nodegraph.cpp @@ -510,12 +510,29 @@ void NodeGraph::handleSelectionChange() auto selected = this->_scene->selectedItems(); for (auto item : selected) { if (item->type() == (int)SceneItemType::Node) { + // emit nulls first so downstream handlers clear before setting new selection + emit frameSelectionChanged(FramePtr(nullptr)); + emit commentSelectionChanged(CommentPtr(nullptr)); emit nodeSelectionChanged(((Node*)item)->sharedFromThis()); return; } + if (item->type() == (int)SceneItemType::Frame) { + emit nodeSelectionChanged(NodePtr(nullptr)); + emit commentSelectionChanged(CommentPtr(nullptr)); + emit frameSelectionChanged(((Frame*)item)->sharedFromThis()); + return; + } + if (item->type() == (int)SceneItemType::Comment) { + emit nodeSelectionChanged(NodePtr(nullptr)); + emit frameSelectionChanged(FramePtr(nullptr)); + emit commentSelectionChanged(((Comment*)item)->sharedFromThis()); + return; + } } emit nodeSelectionChanged(NodePtr(nullptr)); + emit frameSelectionChanged(FramePtr(nullptr)); + emit commentSelectionChanged(CommentPtr(nullptr)); } NodeGraph::~NodeGraph() {} diff --git a/src/nodegraph/nodegraph.h b/src/nodegraph/nodegraph.h index b25d062b..7ad294c6 100644 --- a/src/nodegraph/nodegraph.h +++ b/src/nodegraph/nodegraph.h @@ -118,6 +118,10 @@ class NodeGraph : public QGraphicsView { void nodeSelectionChanged(const NodePtr& node); void nodeDoubleClicked(const NodePtr& node); + // null ptr means no active frame/comment selected + void frameSelectionChanged(const FramePtr& frame); + void commentSelectionChanged(const CommentPtr& comment); + void itemsDeleted(QList nodes, QList cons); }; } // namespace nodegraph \ No newline at end of file diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 2a50726f..00dea01a 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -96,6 +96,36 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) } }); + connect(this->graphWidget, &GraphWidget::frameSelectionChanged, + [this](const FramePtr& frame) { + if (!!frame) { + this->propWidget->setSelectedFrame(frame); + } + else { + this->propWidget->clearSelection(); + } + }); + + connect(this->graphWidget, &GraphWidget::commentSelectionChanged, + [this](const CommentPtr& comment) { + if (!!comment) { + this->propWidget->setSelectedComment(comment); + } + else { + this->propWidget->clearSelection(); + } + }); + + connect(this->propWidget, &PropertiesWidget::framePropertyChanged, + [this](const FramePtr& frame) { + this->graphWidget->syncFrameToScene(frame); + }); + + connect(this->propWidget, &PropertiesWidget::commentPropertyChanged, + [this](const CommentPtr& comment) { + this->graphWidget->syncCommentToScene(comment); + }); + connect(this->propWidget, &PropertiesWidget::propertyUpdated, [this](const QString& name, const QVariant& value) { if (this->renderer && !!this->project) { @@ -441,6 +471,8 @@ void MainWindow::setupDocks() rightArea); setWidgetRatiosInArea(leftArea, {0.5f, 0.5f}); setWidgetRatiosInArea(rightArea, {0.5f, 0.5f}); + + QTimer::singleShot(0, this, [this]() { graphWidget->setFocus(); }); } ads::CDockAreaWidget* MainWindow::addDock(const QString& title, diff --git a/src/texturelab/widgets/graphwidget.cpp b/src/texturelab/widgets/graphwidget.cpp index 06a897d6..655810d0 100644 --- a/src/texturelab/widgets/graphwidget.cpp +++ b/src/texturelab/widgets/graphwidget.cpp @@ -23,6 +23,24 @@ #include "nodegraph.h" #include "nodesearchpopup.h" +void GraphWidget::syncFrameToScene(const FramePtr& frame) +{ + if (!frame || !scene) + return; + auto ngFrame = scene->getFrameById(frame->id); + if (ngFrame) + ngFrame->setTitle(frame->text); +} + +void GraphWidget::syncCommentToScene(const CommentPtr& comment) +{ + if (!comment || !scene) + return; + auto ngComment = scene->getCommentById(comment->id); + if (ngComment) + ngComment->setText(comment->text); +} + GraphWidget::GraphWidget() : QMainWindow(nullptr) { graph = new nodegraph::NodeGraph(this); @@ -134,6 +152,26 @@ GraphWidget::GraphWidget() : QMainWindow(nullptr) renderer->update(); }); + connect(graph, &nodegraph::NodeGraph::frameSelectionChanged, + [=](nodegraph::FramePtr ngFrame) { + if (!ngFrame || !project) { + emit frameSelectionChanged(FramePtr(nullptr)); + return; + } + auto modelFrame = project->frames.value(ngFrame->id()); + emit frameSelectionChanged(modelFrame); + }); + + connect(graph, &nodegraph::NodeGraph::commentSelectionChanged, + [=](nodegraph::CommentPtr ngComment) { + if (!ngComment || !project) { + emit commentSelectionChanged(CommentPtr(nullptr)); + return; + } + auto modelComment = project->comments.value(ngComment->id()); + emit commentSelectionChanged(modelComment); + }); + // library = nullptr; } @@ -320,11 +358,27 @@ void GraphWidget::dropEvent(QDropEvent* evt) auto frame = nodegraph::Frame::create(); frame->setPos(scenePos); scene->addFrame(frame); + + if (project) { + auto modelFrame = FramePtr(new Frame()); + modelFrame->id = frame->id(); + modelFrame->text = frame->title(); + modelFrame->pos = QVector2D(scenePos.x(), scenePos.y()); + project->frames[modelFrame->id] = modelFrame; + } } else if (data->itemType == PopupItemType::Comment) { auto comment = nodegraph::Comment::create(); comment->setPos(scenePos); scene->addComment(comment); + + if (project) { + auto modelComment = CommentPtr(new Comment()); + modelComment->id = comment->id(); + modelComment->text = comment->text(); + modelComment->pos = QVector2D(scenePos.x(), scenePos.y()); + project->comments[modelComment->id] = modelComment; + } } else { auto node = project->library->createNode(data->libraryItemName); @@ -376,11 +430,27 @@ void GraphWidget::addItemFromSearch(const QString& name, PopupItemType type, auto frame = nodegraph::Frame::create(); frame->setPos(scenePos); scene->addFrame(frame); + + if (project) { + auto modelFrame = FramePtr(new Frame()); + modelFrame->id = frame->id(); + modelFrame->text = frame->title(); + modelFrame->pos = QVector2D(scenePos.x(), scenePos.y()); + project->frames[modelFrame->id] = modelFrame; + } } else if (type == PopupItemType::Comment) { auto comment = nodegraph::Comment::create(); comment->setPos(scenePos); scene->addComment(comment); + + if (project) { + auto modelComment = CommentPtr(new Comment()); + modelComment->id = comment->id(); + modelComment->text = comment->text(); + modelComment->pos = QVector2D(scenePos.x(), scenePos.y()); + project->comments[modelComment->id] = modelComment; + } } else { if (!project || !project->library) diff --git a/src/texturelab/widgets/graphwidget.h b/src/texturelab/widgets/graphwidget.h index 0ab7e86b..78d61936 100644 --- a/src/texturelab/widgets/graphwidget.h +++ b/src/texturelab/widgets/graphwidget.h @@ -19,8 +19,12 @@ class Library; class TextureProject; class TextureNode; +class Comment; +class Frame; typedef QSharedPointer TextureProjectPtr; typedef QSharedPointer TextureNodePtr; +typedef QSharedPointer CommentPtr; +typedef QSharedPointer FramePtr; class GraphWidget : public QMainWindow { Q_OBJECT @@ -38,6 +42,9 @@ class GraphWidget : public QMainWindow { void setTextureRenderer(TextureRenderer* renderer); void syncPositionsToModel(); + void syncFrameToScene(const FramePtr& frame); + void syncCommentToScene(const CommentPtr& comment); + nodegraph::NodeGraph* graph; // Library* library; nodegraph::ScenePtr scene; @@ -62,4 +69,6 @@ class GraphWidget : public QMainWindow { signals: void nodeSelectionChanged(const TextureNodePtr& node); void nodeDoubleClicked(const TextureNodePtr& node); + void frameSelectionChanged(const FramePtr& frame); + void commentSelectionChanged(const CommentPtr& comment); }; \ No newline at end of file diff --git a/src/texturelab/widgets/properties/propertieswidget.cpp b/src/texturelab/widgets/properties/propertieswidget.cpp index 9a168eaf..0afd56ef 100644 --- a/src/texturelab/widgets/properties/propertieswidget.cpp +++ b/src/texturelab/widgets/properties/propertieswidget.cpp @@ -5,6 +5,7 @@ #include "propwidgets.h" #include +#include PropertiesWidget::PropertiesWidget() : QWidget() { @@ -31,10 +32,10 @@ PropertiesWidget::PropertiesWidget() : QWidget() void PropertiesWidget::setSelectedNode(const TextureNodePtr& node) { qDebug() << "Displaying properties for node: " << node->title; - this->selectedNode = node; - // clear current properties + // clear current properties first, then assign (clearSelection resets selectedNode) this->clearSelection(); + this->selectedNode = node; auto layout = (QVBoxLayout*)this->layout(); @@ -233,9 +234,74 @@ void PropertiesWidget::addBasePropsToLayout() layout->addWidget(seedWidget); } +void PropertiesWidget::setSelectedFrame(const FramePtr& frame) +{ + this->clearSelection(); + if (!frame) + return; + + this->selectedFrame = frame; + displayMode = PropertyDisplayMode::Frame; + + auto layout = (QVBoxLayout*)this->layout(); + + auto titleLabel = new QLabel("Frame"); + titleLabel->setStyleSheet("font-weight: bold; margin-bottom: 4px;"); + layout->addWidget(titleLabel); + + auto titleProp = new StringProp(); + titleProp->displayName = "Title"; + titleProp->value = frame->text; + auto titleWidget = new StringPropWidget(); + titleWidget->setProp(titleProp); + propWidgets.append(titleWidget); + + connect(titleWidget, &StringPropWidget::valueChanged, [=](const QString& value) { + frame->text = value; + emit framePropertyChanged(frame); + }); + layout->addWidget(titleWidget); + + layout->addStretch(1); +} + +void PropertiesWidget::setSelectedComment(const CommentPtr& comment) +{ + this->clearSelection(); + if (!comment) + return; + + this->selectedComment = comment; + displayMode = PropertyDisplayMode::Comment; + + auto layout = (QVBoxLayout*)this->layout(); + + auto titleLabel = new QLabel("Comment"); + titleLabel->setStyleSheet("font-weight: bold; margin-bottom: 4px;"); + layout->addWidget(titleLabel); + + auto textProp = new StringProp(); + textProp->displayName = "Text"; + textProp->value = comment->text; + auto textWidget = new StringPropWidget(); + textWidget->setProp(textProp); + propWidgets.append(textWidget); + + connect(textWidget, &StringPropWidget::valueChanged, [=](const QString& value) { + comment->text = value; + emit commentPropertyChanged(comment); + }); + layout->addWidget(textWidget); + + layout->addStretch(1); +} + void PropertiesWidget::clearSelection() { displayMode = PropertyDisplayMode::None; + selectedNode.clear(); + selectedFrame.clear(); + selectedComment.clear(); auto layout = this->layout(); diff --git a/src/texturelab/widgets/properties/propertieswidget.h b/src/texturelab/widgets/properties/propertieswidget.h index 518729bd..a11887f0 100644 --- a/src/texturelab/widgets/properties/propertieswidget.h +++ b/src/texturelab/widgets/properties/propertieswidget.h @@ -5,8 +5,12 @@ class TextureProject; class TextureNode; +class Comment; +class Frame; typedef QSharedPointer TextureProjectPtr; typedef QSharedPointer TextureNodePtr; +typedef QSharedPointer CommentPtr; +typedef QSharedPointer FramePtr; class EnumProp; class IntProp; @@ -24,6 +28,8 @@ class PropertiesWidget : public QWidget { TextureProjectPtr project; TextureNodePtr selectedNode; + FramePtr selectedFrame; + CommentPtr selectedComment; // base props EnumProp* textureChannelProp; @@ -33,6 +39,8 @@ class PropertiesWidget : public QWidget { PropertiesWidget(); void setSelectedNode(const TextureNodePtr& node); + void setSelectedFrame(const FramePtr& frame); + void setSelectedComment(const CommentPtr& comment); void clearSelection(); void setProject(const TextureProjectPtr& project); @@ -44,4 +52,6 @@ class PropertiesWidget : public QWidget { void propertyUpdated(const QString& name, const QVariant& value); void textureChannelUpdated(const TextureChannel& name, const TextureNodePtr& node); + void framePropertyChanged(const FramePtr& frame); + void commentPropertyChanged(const CommentPtr& comment); }; \ No newline at end of file From d7331dbd563338e470d4519b2bad9475a0ba93ca Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 6 Jun 2026 17:42:40 -0500 Subject: [PATCH 078/164] update frame props --- src/nodegraph/graph/frame.cpp | 9 ++++++- src/texturelab/models.h | 1 + src/texturelab/project.cpp | 4 +++ src/texturelab/widgets/graphwidget.cpp | 5 +++- .../widgets/properties/propertieswidget.cpp | 14 +++++++++++ .../widgets/properties/propwidgets.cpp | 25 +++++++++++++++++-- .../widgets/properties/propwidgets.h | 3 +++ 7 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/nodegraph/graph/frame.cpp b/src/nodegraph/graph/frame.cpp index 3845f2c6..f46f91a7 100644 --- a/src/nodegraph/graph/frame.cpp +++ b/src/nodegraph/graph/frame.cpp @@ -204,8 +204,15 @@ void Frame::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, // Draw title if enabled if (_showTitle && !_title.isEmpty()) { + QFont font("Arial", 10, QFont::Bold); + painter->setFont(font); + + // shadow pass + painter->setPen(QColor(0, 0, 0, 160)); + painter->drawText(handleRect.translated(1, 1), Qt::AlignCenter, _title); + + // text pass painter->setPen(QColor(255, 255, 255)); - painter->setFont(QFont("Arial", 10, QFont::Bold)); painter->drawText(handleRect, Qt::AlignCenter, _title); } diff --git a/src/texturelab/models.h b/src/texturelab/models.h index 0aee110d..8297c742 100644 --- a/src/texturelab/models.h +++ b/src/texturelab/models.h @@ -209,6 +209,7 @@ class Frame : public QEnableSharedFromThis { public: QString id; QString text; + QColor color = QColor(25, 0, 51); QVector2D pos; QVector2D size; diff --git a/src/texturelab/project.cpp b/src/texturelab/project.cpp index 620e941e..c32630ac 100644 --- a/src/texturelab/project.cpp +++ b/src/texturelab/project.cpp @@ -110,6 +110,9 @@ TextureProjectPtr Project::loadTexture(QString path) frame->pos = QVector2D(obj["x"].toDouble(), obj["y"].toDouble()); frame->size = QVector2D(obj["width"].toDouble(300), obj["height"].toDouble(200)); + auto colorStr = obj["color"].toString(); + if (!colorStr.isEmpty()) + frame->color = QColor(colorStr); texture->frames[frame->id] = frame; } @@ -203,6 +206,7 @@ QByteArray Project::saveTexture(TextureProjectPtr texture) QJsonObject obj; obj["id"] = frame->id; obj["title"] = frame->text; + obj["color"] = frame->color.name(QColor::HexRgb); obj["x"] = frame->pos.x(); obj["y"] = frame->pos.y(); obj["width"] = frame->size.x(); diff --git a/src/texturelab/widgets/graphwidget.cpp b/src/texturelab/widgets/graphwidget.cpp index 655810d0..9bc53d8a 100644 --- a/src/texturelab/widgets/graphwidget.cpp +++ b/src/texturelab/widgets/graphwidget.cpp @@ -28,8 +28,10 @@ void GraphWidget::syncFrameToScene(const FramePtr& frame) if (!frame || !scene) return; auto ngFrame = scene->getFrameById(frame->id); - if (ngFrame) + if (ngFrame) { ngFrame->setTitle(frame->text); + ngFrame->setColor(frame->color); + } } void GraphWidget::syncCommentToScene(const CommentPtr& comment) @@ -278,6 +280,7 @@ void GraphWidget::setTextureProject(TextureProjectPtr project) auto gframe = nodegraph::Frame::create(); gframe->setId(frame->id); gframe->setTitle(frame->text); + gframe->setColor(frame->color); gframe->setPos(frame->pos.x(), frame->pos.y()); if (frame->size.x() > 0 && frame->size.y() > 0) gframe->setSize(frame->size.x(), frame->size.y()); diff --git a/src/texturelab/widgets/properties/propertieswidget.cpp b/src/texturelab/widgets/properties/propertieswidget.cpp index 0afd56ef..f637f270 100644 --- a/src/texturelab/widgets/properties/propertieswidget.cpp +++ b/src/texturelab/widgets/properties/propertieswidget.cpp @@ -262,6 +262,19 @@ void PropertiesWidget::setSelectedFrame(const FramePtr& frame) }); layout->addWidget(titleWidget); + auto colorProp = new ColorProp(); + colorProp->displayName = "Color"; + colorProp->value = frame->color; + auto colorWidget = new ColorPropWidget(); + colorWidget->setProp(colorProp); + propWidgets.append(colorWidget); + + connect(colorWidget, &ColorPropWidget::valueChanged, [=](const QColor& color) { + frame->color = color; + emit framePropertyChanged(frame); + }); + layout->addWidget(colorWidget); + layout->addStretch(1); } @@ -285,6 +298,7 @@ void PropertiesWidget::setSelectedComment(const CommentPtr& comment) textProp->value = comment->text; auto textWidget = new StringPropWidget(); textWidget->setProp(textProp); + textWidget->setMultiline(true); propWidgets.append(textWidget); connect(textWidget, &StringPropWidget::valueChanged, [=](const QString& value) { diff --git a/src/texturelab/widgets/properties/propwidgets.cpp b/src/texturelab/widgets/properties/propwidgets.cpp index 0a095961..aa0e4878 100644 --- a/src/texturelab/widgets/properties/propwidgets.cpp +++ b/src/texturelab/widgets/properties/propwidgets.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -239,29 +240,49 @@ StringPropWidget::StringPropWidget() auto vlayout = new QVBoxLayout(this); this->setLayout(vlayout); - // label label = new QLabel(this); label->setText(""); vlayout->addWidget(label); - // line edit lineEdit = new QLineEdit(this); vlayout->addWidget(lineEdit); + textEdit = new QPlainTextEdit(this); + textEdit->hide(); + vlayout->addWidget(textEdit); + this->setFixedHeight(80); connect(lineEdit, &QLineEdit::textChanged, [=](const QString& text) { emit valueChanged(text); }); + + connect(textEdit, &QPlainTextEdit::textChanged, + [=]() { emit valueChanged(textEdit->toPlainText()); }); } void StringPropWidget::setProp(StringProp* prop) { label->setText(prop->displayName); lineEdit->setText(prop->value); + textEdit->setPlainText(prop->value); this->prop = prop; } +void StringPropWidget::setMultiline(bool multiline) +{ + if (multiline) { + lineEdit->hide(); + textEdit->show(); + setFixedHeight(120); + } + else { + textEdit->hide(); + lineEdit->show(); + setFixedHeight(80); + } +} + // BOOL PROP WIDGET // https://stackoverflow.com/a/19007951 BoolPropWidget::BoolPropWidget() diff --git a/src/texturelab/widgets/properties/propwidgets.h b/src/texturelab/widgets/properties/propwidgets.h index 661262a0..26e6a3f0 100644 --- a/src/texturelab/widgets/properties/propwidgets.h +++ b/src/texturelab/widgets/properties/propwidgets.h @@ -9,6 +9,7 @@ class QSpinBox; class QComboBox; class QPushButton; class QLineEdit; +class QPlainTextEdit; struct FloatProp; struct IntProp; @@ -75,12 +76,14 @@ class StringPropWidget : public QWidget { QLabel* label; QLineEdit* lineEdit; + QPlainTextEdit* textEdit; StringProp* prop; public: StringPropWidget(); void setProp(StringProp* prop); + void setMultiline(bool multiline); signals: void valueChanged(QString); }; From 3cfdca60e3fbc2958ed02bd37b04414b2167b26c Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 6 Jun 2026 19:07:44 -0500 Subject: [PATCH 079/164] display texture channel above node --- src/nodegraph/graph/scene.cpp | 25 +++++++++++++++++++++++++ src/nodegraph/graph/scene.h | 3 +++ src/texturelab/mainwindow.cpp | 35 +++++++++++++++++++++++++++++++++++ src/texturelab/mainwindow.h | 1 + 4 files changed, 64 insertions(+) diff --git a/src/nodegraph/graph/scene.cpp b/src/nodegraph/graph/scene.cpp index 47f4ed0a..e6c5c1a1 100644 --- a/src/nodegraph/graph/scene.cpp +++ b/src/nodegraph/graph/scene.cpp @@ -256,6 +256,16 @@ Node::Node() font.setPixelSize(12); text->setFont(font); + channelText = new QGraphicsTextItem(this); + channelText->setFlag(QGraphicsItem::ItemIsFocusable, false); + channelText->setFlag(QGraphicsItem::ItemIsSelectable, false); + channelText->setDefaultTextColor(QColor(200, 255, 200)); + channelText->setZValue(5); + channelText->hide(); + QFont chFont = channelText->font(); + chFont.setPixelSize(12); + channelText->setFont(chFont); + QGraphicsDropShadowEffect* effect = new QGraphicsDropShadowEffect; effect->setBlurRadius(20); effect->setXOffset(0); @@ -300,6 +310,20 @@ void Node::setThumbnail(const QPixmap& pixmap) this->update(); } +void Node::setChannel(QString ch) +{ + this->channel = ch; + if (ch.isEmpty()) { + channelText->hide(); + } else { + channelText->setPlainText(ch.toUpper()); + QFontMetrics fm(channelText->font()); + int textW = fm.horizontalAdvance(ch.toUpper()); + channelText->setPos((width - textW) / 2.0, -20); + channelText->show(); + } +} + const QVector Node::getInPorts() const { return inPorts; } const QVector Node::getOutPorts() const { return outPorts; } @@ -584,6 +608,7 @@ void Node::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, // draw border painter->setPen(QPen(borderColor, 3)); painter->drawRoundedRect(rect, titleRadius, titleRadius); + } Node::~Node() diff --git a/src/nodegraph/graph/scene.h b/src/nodegraph/graph/scene.h index 68a3a8e9..ee10ae16 100644 --- a/src/nodegraph/graph/scene.h +++ b/src/nodegraph/graph/scene.h @@ -80,7 +80,9 @@ class Node : public QGraphicsObject, public QEnableSharedFromThis { GLuint texId = 0; QGraphicsTextItem* text; + QGraphicsTextItem* channelText; QString name; + QString channel; QPixmap thumbnail; @@ -114,6 +116,7 @@ class Node : public QGraphicsObject, public QEnableSharedFromThis { const QVector getOutPorts() const; void setName(QString name); + void setChannel(QString ch); void setCenter(float x, float y); QPointF getCenter() const; void setThumbnail(const QPixmap& pixmap); diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 00dea01a..de8f5725 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -41,6 +41,7 @@ #include "props.h" #include "graphics/texturerenderer.h" +#include "graph/scene.h" MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { @@ -163,6 +164,7 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) // assign channel to viewer // do this crudely by just reassigning all node textures this->passTextureChannelsToViewer3D(); + this->syncChannelLabelsToScene(); } // this->view3DWidget->update(); @@ -233,6 +235,38 @@ void MainWindow::passTextureChannelsToViewer3D() } } +static QString channelName(TextureChannel ch) +{ + switch (ch) { + case TextureChannel::Albedo: return "Albedo"; + case TextureChannel::Normal: return "Normal"; + case TextureChannel::Metalness: return "Metalness"; + case TextureChannel::Roughness: return "Roughness"; + case TextureChannel::Height: return "Height"; + case TextureChannel::Alpha: return "Alpha"; + case TextureChannel::AO: return "AO"; + default: return ""; + } +} + +void MainWindow::syncChannelLabelsToScene() +{ + if (!project || !graphWidget->scene) + return; + + // clear all labels first + for (auto& node : graphWidget->scene->nodes) + node->setChannel(""); + + // set labels from project state + for (auto ch : project->textureChannels.keys()) { + auto nodeId = project->textureChannels[ch]; + auto sceneNode = graphWidget->scene->nodes.value(nodeId); + if (sceneNode) + sceneNode->setChannel(channelName(ch)); + } +} + void MainWindow::setProject(TextureProjectPtr project) { // Clear widget state from the old project @@ -251,6 +285,7 @@ void MainWindow::setProject(TextureProjectPtr project) this->project = project; this->graphWidget->setTextureProject(project); + this->syncChannelLabelsToScene(); this->libraryWidget->setLibrary(project->library); this->propWidget->clearSelection(); diff --git a/src/texturelab/mainwindow.h b/src/texturelab/mainwindow.h index 5e0f1402..226d41a7 100644 --- a/src/texturelab/mainwindow.h +++ b/src/texturelab/mainwindow.h @@ -44,6 +44,7 @@ class MainWindow : public QMainWindow { void handleExport(const QString& destination, const QString& pattern); void passTextureChannelsToViewer3D(); + void syncChannelLabelsToScene(); void setProject(TextureProjectPtr project); From b379e6f804e264b0172b1ae15d25add28dd7777f Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 6 Jun 2026 19:15:48 -0500 Subject: [PATCH 080/164] map range no longer affects alpha channel --- src/texturelab/libraries/v1/maprange.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/texturelab/libraries/v1/maprange.cpp b/src/texturelab/libraries/v1/maprange.cpp index 03af7f4f..759882a0 100644 --- a/src/texturelab/libraries/v1/maprange.cpp +++ b/src/texturelab/libraries/v1/maprange.cpp @@ -18,10 +18,8 @@ void MapRangeNode::init() { vec4 col = texture(color,uv); - // color range coming in float inDiff = prop_in_max - prop_in_min; - col = (col-prop_in_min) / inDiff; - + col.rgb = (col.rgb - prop_in_min) / inDiff; float outDiff = prop_out_max - prop_out_min; col.rgb = prop_out_min + col.rgb * vec3(outDiff); From 239b35d110db90f81ad038444d51e8499015f009 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 6 Jun 2026 20:38:23 -0500 Subject: [PATCH 081/164] begin copy/paste impl --- src/texturelab/CMakeLists.txt | 2 + src/texturelab/clipboard.cpp | 207 +++++++++++++++++++++++++ src/texturelab/clipboard.h | 23 +++ src/texturelab/mainwindow.cpp | 6 +- src/texturelab/widgets/graphwidget.cpp | 175 +++++++++++++++++++++ src/texturelab/widgets/graphwidget.h | 5 + 6 files changed, 415 insertions(+), 3 deletions(-) create mode 100644 src/texturelab/clipboard.cpp create mode 100644 src/texturelab/clipboard.h diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 8525d317..a6dcb127 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -146,6 +146,8 @@ set(PROJECT_SOURCES ./main.cpp ./mainwindow.cpp ./mainwindow.h + ./clipboard.h + ./clipboard.cpp ./exporter.h ./exporter.cpp ./utils.h diff --git a/src/texturelab/clipboard.cpp b/src/texturelab/clipboard.cpp new file mode 100644 index 00000000..4bbcd90d --- /dev/null +++ b/src/texturelab/clipboard.cpp @@ -0,0 +1,207 @@ +#include "clipboard.h" +#include "libraries/library.h" +#include "props.h" +#include +#include +#include +#include +#include +#include + +const QString Clipboard::PREFIX = "texturelab-clipboard:"; + +void Clipboard::copyItems(TextureProjectPtr project, + const QList& nodeIds, + const QList& frameIds, + const QList& commentIds) +{ + QSet nodeIdSet(nodeIds.begin(), nodeIds.end()); + + QJsonObject root; + + // Nodes + QJsonArray nodeArray; + for (const auto& id : nodeIds) { + auto node = project->nodes.value(id); + if (!node) + continue; + + QJsonObject obj; + obj["id"] = node->id; + obj["typeName"] = node->typeName; + obj["exportName"] = node->exportName; + obj["randomSeed"] = (double)node->randomSeed; + obj["x"] = node->pos.x(); + obj["y"] = node->pos.y(); + + QJsonObject props; + for (auto key : node->props.keys()) + props[key] = node->props[key]->toJsonValue(); + obj["properties"] = props; + + nodeArray.append(obj); + } + root["nodes"] = nodeArray; + + // Connections — only those fully within the selection + QJsonArray conArray; + for (auto& con : project->connections) { + if (nodeIdSet.contains(con->leftNode->id) && + nodeIdSet.contains(con->rightNode->id)) { + QJsonObject obj; + obj["leftNodeId"] = con->leftNode->id; + obj["rightNodeId"] = con->rightNode->id; + obj["rightNodeInput"] = con->rightNodeInputName; + conArray.append(obj); + } + } + root["connections"] = conArray; + + // Comments + QJsonArray commentArray; + for (const auto& id : commentIds) { + auto comment = project->comments.value(id); + if (!comment) + continue; + QJsonObject obj; + obj["text"] = comment->text; + obj["x"] = comment->pos.x(); + obj["y"] = comment->pos.y(); + commentArray.append(obj); + } + root["comments"] = commentArray; + + // Frames + QJsonArray frameArray; + for (const auto& id : frameIds) { + auto frame = project->frames.value(id); + if (!frame) + continue; + QJsonObject obj; + obj["title"] = frame->text; + obj["color"] = frame->color.name(QColor::HexRgb); + obj["x"] = frame->pos.x(); + obj["y"] = frame->pos.y(); + obj["width"] = frame->size.x(); + obj["height"] = frame->size.y(); + frameArray.append(obj); + } + root["frames"] = frameArray; + + QJsonDocument doc(root); + QApplication::clipboard()->setText(PREFIX + doc.toJson(QJsonDocument::Compact)); +} + +bool Clipboard::hasData() +{ + return QApplication::clipboard()->text().startsWith(PREFIX); +} + +bool Clipboard::pasteItems(TextureProjectPtr project, + QList& outNodes, + QList& outConnections, + QList& outComments, + QList& outFrames) +{ + if (!project || !project->library) + return false; + + QString text = QApplication::clipboard()->text(); + if (!text.startsWith(PREFIX)) + return false; + + QJsonParseError err; + auto doc = QJsonDocument::fromJson(text.mid(PREFIX.length()).toUtf8(), &err); + if (err.error || !doc.isObject()) + return false; + + auto root = doc.object(); + + static constexpr double OFFSET = 20.0; + + // Build old→new node ID map + QMap nodeIdMap; + for (auto item : root["nodes"].toArray()) { + auto oldId = item.toObject()["id"].toString(); + nodeIdMap[oldId] = QUuid::createUuid().toString(QUuid::WithoutBraces); + } + + // Nodes + for (auto item : root["nodes"].toArray()) { + auto obj = item.toObject(); + auto typeName = obj["typeName"].toString(); + auto node = project->library->createNode(typeName); + if (!node) + continue; + + node->id = nodeIdMap[obj["id"].toString()]; + node->exportName = obj["exportName"].toString(); + node->randomSeed = (long)obj["randomSeed"].toDouble(0); + node->pos = QVector2D((float)obj["x"].toDouble() + OFFSET, + (float)obj["y"].toDouble() + OFFSET); + + auto propObj = obj["properties"].toObject(); + for (auto key : propObj.keys()) { + auto prop = node->getProp(key); + if (prop) + prop->fromJsonValue(propObj[key]); + } + + outNodes.append(node); + } + + // Connections + for (auto item : root["connections"].toArray()) { + auto obj = item.toObject(); + auto newLeftId = nodeIdMap.value(obj["leftNodeId"].toString()); + auto newRightId = nodeIdMap.value(obj["rightNodeId"].toString()); + if (newLeftId.isEmpty() || newRightId.isEmpty()) + continue; + + // Find the model nodes from outNodes + TextureNodePtr leftNode, rightNode; + for (auto& n : outNodes) { + if (n->id == newLeftId) + leftNode = n; + if (n->id == newRightId) + rightNode = n; + } + if (!leftNode || !rightNode) + continue; + + auto con = ConnectionPtr(new Connection()); + con->id = QUuid::createUuid().toString(QUuid::WithoutBraces); + con->leftNode = leftNode; + con->rightNode = rightNode; + con->leftNodeOutputName = "output"; + con->rightNodeInputName = obj["rightNodeInput"].toString(); + outConnections.append(con); + } + + // Comments + for (auto item : root["comments"].toArray()) { + auto obj = item.toObject(); + auto comment = CommentPtr(new Comment()); + comment->id = QUuid::createUuid().toString(QUuid::WithoutBraces); + comment->text = obj["text"].toString(); + comment->pos = QVector2D((float)obj["x"].toDouble() + OFFSET, + (float)obj["y"].toDouble() + OFFSET); + outComments.append(comment); + } + + // Frames + for (auto item : root["frames"].toArray()) { + auto obj = item.toObject(); + auto frame = FramePtr(new Frame()); + frame->id = QUuid::createUuid().toString(QUuid::WithoutBraces); + frame->text = obj["title"].toString(); + frame->color = QColor(obj["color"].toString()); + frame->pos = QVector2D((float)obj["x"].toDouble() + OFFSET, + (float)obj["y"].toDouble() + OFFSET); + frame->size = QVector2D((float)obj["width"].toDouble(), + (float)obj["height"].toDouble()); + outFrames.append(frame); + } + + return !outNodes.isEmpty() || !outComments.isEmpty() || !outFrames.isEmpty(); +} diff --git a/src/texturelab/clipboard.h b/src/texturelab/clipboard.h new file mode 100644 index 00000000..1e101106 --- /dev/null +++ b/src/texturelab/clipboard.h @@ -0,0 +1,23 @@ +#pragma once + +#include "models.h" +#include + +class Clipboard { +public: + static void copyItems(TextureProjectPtr project, + const QList& nodeIds, + const QList& frameIds, + const QList& commentIds); + + static bool pasteItems(TextureProjectPtr project, + QList& outNodes, + QList& outConnections, + QList& outComments, + QList& outFrames); + + static bool hasData(); + +private: + static const QString PREFIX; +}; diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index de8f5725..b3105657 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -372,9 +372,9 @@ void MainWindow::setupMenus() auto editMenu = this->menuBar()->addMenu("Edit"); editMenu->addAction("Undo", []() {}); editMenu->addAction("Redo", []() {}); - editMenu->addAction("Cut", []() {}); - editMenu->addAction("Copy", []() {}); - editMenu->addAction("Paste", []() {}); + editMenu->addAction("Cut", [=]() { graphWidget->executeCut(); }); + editMenu->addAction("Copy", [=]() { graphWidget->executeCopy(); }); + editMenu->addAction("Paste", [=]() { graphWidget->executePaste(); }); auto examplesMenu = this->menuBar()->addMenu("Examples"); diff --git a/src/texturelab/widgets/graphwidget.cpp b/src/texturelab/widgets/graphwidget.cpp index 9bc53d8a..4c258fbe 100644 --- a/src/texturelab/widgets/graphwidget.cpp +++ b/src/texturelab/widgets/graphwidget.cpp @@ -1,4 +1,5 @@ #include "graphwidget.h" +#include "../clipboard.h" #include #include #include @@ -9,6 +10,7 @@ #include #include #include +#include #include #include @@ -175,6 +177,18 @@ GraphWidget::GraphWidget() : QMainWindow(nullptr) }); // library = nullptr; + + auto copyShortcut = new QShortcut(QKeySequence::Copy, this); + copyShortcut->setContext(Qt::WidgetWithChildrenShortcut); + connect(copyShortcut, &QShortcut::activated, this, &GraphWidget::executeCopy); + + auto cutShortcut = new QShortcut(QKeySequence::Cut, this); + cutShortcut->setContext(Qt::WidgetWithChildrenShortcut); + connect(cutShortcut, &QShortcut::activated, this, &GraphWidget::executeCut); + + auto pasteShortcut = new QShortcut(QKeySequence::Paste, this); + pasteShortcut->setContext(Qt::WidgetWithChildrenShortcut); + connect(pasteShortcut, &QShortcut::activated, this, &GraphWidget::executePaste); } void GraphWidget::setupToolbar() @@ -423,6 +437,167 @@ void GraphWidget::keyPressEvent(QKeyEvent* event) } } +void GraphWidget::executeCopy() +{ + if (!project || !scene) + return; + + syncPositionsToModel(); + + QList nodeIds, frameIds, commentIds; + for (auto item : scene->selectedItems()) { + if (item->type() == (int)nodegraph::SceneItemType::Node) { + auto node = qgraphicsitem_cast(item); + if (node) + nodeIds.append(node->id()); + } + else if (item->type() == (int)nodegraph::SceneItemType::Frame) { + auto frame = qgraphicsitem_cast(item); + if (frame) + frameIds.append(frame->id()); + } + else if (item->type() == (int)nodegraph::SceneItemType::Comment) { + auto comment = qgraphicsitem_cast(item); + if (comment) + commentIds.append(comment->id()); + } + } + + if (nodeIds.isEmpty() && frameIds.isEmpty() && commentIds.isEmpty()) + return; + + Clipboard::copyItems(project, nodeIds, frameIds, commentIds); +} + +void GraphWidget::executeCut() +{ + if (!project || !scene) + return; + + executeCopy(); + + // Collect IDs before modifying the scene + QList nodeIds, frameIds, commentIds; + for (auto item : scene->selectedItems()) { + if (item->type() == (int)nodegraph::SceneItemType::Node) { + auto node = qgraphicsitem_cast(item); + if (node) + nodeIds.append(node->id()); + } + else if (item->type() == (int)nodegraph::SceneItemType::Frame) { + auto frame = qgraphicsitem_cast(item); + if (frame) + frameIds.append(frame->id()); + } + else if (item->type() == (int)nodegraph::SceneItemType::Comment) { + auto comment = qgraphicsitem_cast(item); + if (comment) + commentIds.append(comment->id()); + } + } + + // Remove nodes (and their connections) from scene + model + for (const auto& id : nodeIds) { + auto ngNode = scene->getNodeById(id); + if (ngNode) + scene->removeNode(ngNode); + + for (auto key : project->connections.keys()) { + auto con = project->connections[key]; + if (con->leftNode->id == id || con->rightNode->id == id) { + if (con->leftNode->id == id) + con->rightNode->isDirty = true; + project->connections.remove(key); + } + } + project->nodes.remove(id); + } + + // Remove frames + for (const auto& id : frameIds) { + auto ngFrame = scene->getFrameById(id); + if (ngFrame) + scene->removeFrame(ngFrame); + project->frames.remove(id); + } + + // Remove comments + for (const auto& id : commentIds) { + auto ngComment = scene->getCommentById(id); + if (ngComment) + scene->removeComment(ngComment); + project->comments.remove(id); + } + + emit nodeSelectionChanged(TextureNodePtr(nullptr)); + if (renderer) + renderer->update(); +} + +void GraphWidget::executePaste() +{ + if (!project || !scene) + return; + + QList newNodes; + QList newConnections; + QList newComments; + QList newFrames; + + if (!Clipboard::pasteItems(project, newNodes, newConnections, newComments, + newFrames)) + return; + + scene->clearSelection(); + + // Add nodes + for (auto& node : newNodes) { + project->nodes[node->id] = node; + addNode(node); + auto ngNode = scene->getNodeById(node->id); + if (ngNode) + ngNode->setSelected(true); + } + + // Add connections + for (auto& con : newConnections) { + project->connections[con->id] = con; + auto leftNgNode = scene->getNodeById(con->leftNode->id); + auto rightNgNode = scene->getNodeById(con->rightNode->id); + if (leftNgNode && rightNgNode) + scene->connectNodes(leftNgNode, "output", rightNgNode, + con->rightNodeInputName); + } + + // Add comments + for (auto& comment : newComments) { + project->comments[comment->id] = comment; + auto gcomment = nodegraph::Comment::create(); + gcomment->setId(comment->id); + gcomment->setText(comment->text); + gcomment->setPos(comment->pos.x(), comment->pos.y()); + scene->addComment(gcomment); + gcomment->setSelected(true); + } + + // Add frames + for (auto& frame : newFrames) { + project->frames[frame->id] = frame; + auto gframe = nodegraph::Frame::create(); + gframe->setId(frame->id); + gframe->setTitle(frame->text); + gframe->setColor(frame->color); + gframe->setPos(frame->pos.x(), frame->pos.y()); + if (frame->size.x() > 0 && frame->size.y() > 0) + gframe->setSize(frame->size.x(), frame->size.y()); + scene->addFrame(gframe); + gframe->setSelected(true); + } + + if (renderer) + renderer->update(); +} + void GraphWidget::addItemFromSearch(const QString& name, PopupItemType type, const QPoint& position) { diff --git a/src/texturelab/widgets/graphwidget.h b/src/texturelab/widgets/graphwidget.h index 78d61936..2712761d 100644 --- a/src/texturelab/widgets/graphwidget.h +++ b/src/texturelab/widgets/graphwidget.h @@ -66,6 +66,11 @@ class GraphWidget : public QMainWindow { QComboBox* resolutionPicker; QSpinBox* seedInput; +public slots: + void executeCopy(); + void executeCut(); + void executePaste(); + signals: void nodeSelectionChanged(const TextureNodePtr& node); void nodeDoubleClicked(const TextureNodePtr& node); From 860dc9b506cf7dbb9dec91e037ea76c93435a7e9 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 6 Jun 2026 21:01:00 -0500 Subject: [PATCH 082/164] invalidate nodes after copy/paste --- src/texturelab/widgets/graphwidget.cpp | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/texturelab/widgets/graphwidget.cpp b/src/texturelab/widgets/graphwidget.cpp index 4c258fbe..3c5939b7 100644 --- a/src/texturelab/widgets/graphwidget.cpp +++ b/src/texturelab/widgets/graphwidget.cpp @@ -496,20 +496,25 @@ void GraphWidget::executeCut() } } - // Remove nodes (and their connections) from scene + model + // Remove nodes, propagating dirty through the full downstream subgraph for (const auto& id : nodeIds) { auto ngNode = scene->getNodeById(id); if (ngNode) scene->removeNode(ngNode); + // Capture downstream nodes before their connections are removed + auto downstream = project->getNodeRightOfNode(id); + for (auto key : project->connections.keys()) { auto con = project->connections[key]; - if (con->leftNode->id == id || con->rightNode->id == id) { - if (con->leftNode->id == id) - con->rightNode->isDirty = true; + if (con->leftNode->id == id || con->rightNode->id == id) project->connections.remove(key); - } } + + // BFS-mark all transitive dependents dirty so they re-render + for (auto& dep : downstream) + project->markNodeAsDirty(dep); + project->nodes.remove(id); } @@ -529,7 +534,12 @@ void GraphWidget::executeCut() project->comments.remove(id); } + // Clear properties panel regardless of which item type was selected emit nodeSelectionChanged(TextureNodePtr(nullptr)); + emit frameSelectionChanged(FramePtr(nullptr)); + emit commentSelectionChanged(CommentPtr(nullptr)); + + scene->update(); if (renderer) renderer->update(); } @@ -559,9 +569,10 @@ void GraphWidget::executePaste() ngNode->setSelected(true); } - // Add connections + // Add connections and invalidate the receiving node so it re-renders for (auto& con : newConnections) { project->connections[con->id] = con; + con->rightNode->isDirty = true; auto leftNgNode = scene->getNodeById(con->leftNode->id); auto rightNgNode = scene->getNodeById(con->rightNode->id); if (leftNgNode && rightNgNode) @@ -594,6 +605,7 @@ void GraphWidget::executePaste() gframe->setSelected(true); } + scene->update(); if (renderer) renderer->update(); } From 24b75fc0d98f7a3a4bc7738e2131c90bfddb2492 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 7 Jun 2026 01:16:30 -0500 Subject: [PATCH 083/164] paste now happens relative to view --- src/texturelab/clipboard.cpp | 53 +++++++++++++++++++++----- src/texturelab/clipboard.h | 1 + src/texturelab/widgets/graphwidget.cpp | 6 ++- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/texturelab/clipboard.cpp b/src/texturelab/clipboard.cpp index 4bbcd90d..cb55537f 100644 --- a/src/texturelab/clipboard.cpp +++ b/src/texturelab/clipboard.cpp @@ -7,6 +7,7 @@ #include #include #include +#include const QString Clipboard::PREFIX = "texturelab-clipboard:"; @@ -98,6 +99,7 @@ bool Clipboard::hasData() } bool Clipboard::pasteItems(TextureProjectPtr project, + QPointF viewCenter, QList& outNodes, QList& outConnections, QList& outComments, @@ -117,7 +119,40 @@ bool Clipboard::pasteItems(TextureProjectPtr project, auto root = doc.object(); - static constexpr double OFFSET = 20.0; + // Compute bounding box of all items in the clipboard to find their center + double minX = std::numeric_limits::max(); + double minY = std::numeric_limits::max(); + double maxX = std::numeric_limits::lowest(); + double maxY = std::numeric_limits::lowest(); + + auto expandBBox = [&](double x, double y) { + minX = std::min(minX, x); minY = std::min(minY, y); + maxX = std::max(maxX, x); maxY = std::max(maxY, y); + }; + + for (auto item : root["nodes"].toArray()) { + auto o = item.toObject(); + expandBBox(o["x"].toDouble(), o["y"].toDouble()); + } + for (auto item : root["comments"].toArray()) { + auto o = item.toObject(); + expandBBox(o["x"].toDouble(), o["y"].toDouble()); + } + for (auto item : root["frames"].toArray()) { + auto o = item.toObject(); + expandBBox(o["x"].toDouble(), o["y"].toDouble()); + expandBBox(o["x"].toDouble() + o["width"].toDouble(), + o["y"].toDouble() + o["height"].toDouble()); + } + + // If nothing in the bbox (empty clipboard somehow), fall back to no shift + double offsetX = 0, offsetY = 0; + if (minX <= maxX && minY <= maxY) { + double bboxCenterX = (minX + maxX) / 2.0; + double bboxCenterY = (minY + maxY) / 2.0; + offsetX = viewCenter.x() - bboxCenterX; + offsetY = viewCenter.y() - bboxCenterY; + } // Build old→new node ID map QMap nodeIdMap; @@ -129,16 +164,15 @@ bool Clipboard::pasteItems(TextureProjectPtr project, // Nodes for (auto item : root["nodes"].toArray()) { auto obj = item.toObject(); - auto typeName = obj["typeName"].toString(); - auto node = project->library->createNode(typeName); + auto node = project->library->createNode(obj["typeName"].toString()); if (!node) continue; node->id = nodeIdMap[obj["id"].toString()]; node->exportName = obj["exportName"].toString(); node->randomSeed = (long)obj["randomSeed"].toDouble(0); - node->pos = QVector2D((float)obj["x"].toDouble() + OFFSET, - (float)obj["y"].toDouble() + OFFSET); + node->pos = QVector2D((float)(obj["x"].toDouble() + offsetX), + (float)(obj["y"].toDouble() + offsetY)); auto propObj = obj["properties"].toObject(); for (auto key : propObj.keys()) { @@ -158,7 +192,6 @@ bool Clipboard::pasteItems(TextureProjectPtr project, if (newLeftId.isEmpty() || newRightId.isEmpty()) continue; - // Find the model nodes from outNodes TextureNodePtr leftNode, rightNode; for (auto& n : outNodes) { if (n->id == newLeftId) @@ -184,8 +217,8 @@ bool Clipboard::pasteItems(TextureProjectPtr project, auto comment = CommentPtr(new Comment()); comment->id = QUuid::createUuid().toString(QUuid::WithoutBraces); comment->text = obj["text"].toString(); - comment->pos = QVector2D((float)obj["x"].toDouble() + OFFSET, - (float)obj["y"].toDouble() + OFFSET); + comment->pos = QVector2D((float)(obj["x"].toDouble() + offsetX), + (float)(obj["y"].toDouble() + offsetY)); outComments.append(comment); } @@ -196,8 +229,8 @@ bool Clipboard::pasteItems(TextureProjectPtr project, frame->id = QUuid::createUuid().toString(QUuid::WithoutBraces); frame->text = obj["title"].toString(); frame->color = QColor(obj["color"].toString()); - frame->pos = QVector2D((float)obj["x"].toDouble() + OFFSET, - (float)obj["y"].toDouble() + OFFSET); + frame->pos = QVector2D((float)(obj["x"].toDouble() + offsetX), + (float)(obj["y"].toDouble() + offsetY)); frame->size = QVector2D((float)obj["width"].toDouble(), (float)obj["height"].toDouble()); outFrames.append(frame); diff --git a/src/texturelab/clipboard.h b/src/texturelab/clipboard.h index 1e101106..b5040705 100644 --- a/src/texturelab/clipboard.h +++ b/src/texturelab/clipboard.h @@ -11,6 +11,7 @@ class Clipboard { const QList& commentIds); static bool pasteItems(TextureProjectPtr project, + QPointF viewCenter, QList& outNodes, QList& outConnections, QList& outComments, diff --git a/src/texturelab/widgets/graphwidget.cpp b/src/texturelab/widgets/graphwidget.cpp index 3c5939b7..b0e3ad0f 100644 --- a/src/texturelab/widgets/graphwidget.cpp +++ b/src/texturelab/widgets/graphwidget.cpp @@ -554,8 +554,10 @@ void GraphWidget::executePaste() QList newComments; QList newFrames; - if (!Clipboard::pasteItems(project, newNodes, newConnections, newComments, - newFrames)) + QPointF viewCenter = graph->mapToScene(graph->viewport()->rect().center()); + + if (!Clipboard::pasteItems(project, viewCenter, newNodes, newConnections, + newComments, newFrames)) return; scene->clearSelection(); From bc578b7242391f9da735ad7294dbd697b0f63ae6 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 7 Jun 2026 01:37:14 -0500 Subject: [PATCH 084/164] disable scrolling in comboboxes --- src/texturelab/widgets/graphwidget.cpp | 8 +++++++- src/texturelab/widgets/properties/propwidgets.cpp | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/texturelab/widgets/graphwidget.cpp b/src/texturelab/widgets/graphwidget.cpp index b0e3ad0f..1d9c319a 100644 --- a/src/texturelab/widgets/graphwidget.cpp +++ b/src/texturelab/widgets/graphwidget.cpp @@ -14,6 +14,12 @@ #include #include +class NoWheelComboBox : public QComboBox { +public: + using QComboBox::QComboBox; + void wheelEvent(QWheelEvent* event) override { event->ignore(); } +}; + #include "./graphics/texturerenderer.h" #include "./models.h" #include "./utils.h" @@ -198,7 +204,7 @@ void GraphWidget::setupToolbar() toolbar->addWidget(new QLabel("Resolution: ")); - resolutionPicker = new QComboBox(); + resolutionPicker = new NoWheelComboBox(); for (int res : {32, 64, 128, 256, 512, 1024, 2048, 4096}) resolutionPicker->addItem(QString("%1 x %1").arg(res), res); resolutionPicker->setCurrentIndex(5); // default: 1024 diff --git a/src/texturelab/widgets/properties/propwidgets.cpp b/src/texturelab/widgets/properties/propwidgets.cpp index aa0e4878..74d9932f 100644 --- a/src/texturelab/widgets/properties/propwidgets.cpp +++ b/src/texturelab/widgets/properties/propwidgets.cpp @@ -31,6 +31,12 @@ class NoWheelSlider : public QSlider { void wheelEvent(QWheelEvent* event) override { event->ignore(); } }; +class NoWheelComboBox : public QComboBox { +public: + using QComboBox::QComboBox; + void wheelEvent(QWheelEvent* event) override { event->ignore(); } +}; + // FLOAT PROP WIDGET // https://stackoverflow.com/a/19007951 FloatPropWidget::FloatPropWidget() @@ -210,7 +216,7 @@ EnumPropWidget::EnumPropWidget() vlayout->addWidget(label); // slider - comboBox = new QComboBox(this); + comboBox = new NoWheelComboBox(this); vlayout->addWidget(comboBox); this->setFixedHeight(80); From 7e5178ba554a37377aba1d672e0667814b68b3e4 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 7 Jun 2026 03:00:42 -0500 Subject: [PATCH 085/164] add about dialog --- src/texturelab/CMakeLists.txt | 2 + src/texturelab/assets.qrc | 1 + src/texturelab/mainwindow.cpp | 7 +- src/texturelab/widgets/aboutdialog.cpp | 132 +++++++++++++++++++++++++ src/texturelab/widgets/aboutdialog.h | 16 +++ 5 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 src/texturelab/widgets/aboutdialog.cpp create mode 100644 src/texturelab/widgets/aboutdialog.h diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index a6dcb127..9169d4c0 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -179,6 +179,8 @@ set(PROJECT_SOURCES ./widgets/view2dwidget.cpp ./widgets/view3dwidget.h ./widgets/view3dwidget.cpp + ./widgets/aboutdialog.h + ./widgets/aboutdialog.cpp ./widgets/exportdialog.h ./widgets/exportdialog.cpp ./graphics/texturerenderer.h diff --git a/src/texturelab/assets.qrc b/src/texturelab/assets.qrc index adf69b01..eed61479 100644 --- a/src/texturelab/assets.qrc +++ b/src/texturelab/assets.qrc @@ -94,6 +94,7 @@ ../icons/grid.svg ../icons/crosshair.svg ../icons/copy.svg + ../icons/logo.png ../../public/assets/env/cave_wall/cave_wall_1k.hdr diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index b3105657..9dbadb49 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -27,6 +27,7 @@ #include "DockSplitter.h" #include "exporter.h" +#include "widgets/aboutdialog.h" #include "widgets/exportdialog.h" #include "widgets/graphwidget.h" #include "widgets/librarywidget.h" @@ -411,8 +412,10 @@ void MainWindow::setupMenus() } auto optionsMenu = this->menuBar()->addMenu("Help"); - optionsMenu->addAction("Documentation", []() {}); - optionsMenu->addAction("About", []() {}); + optionsMenu->addAction("About", [this]() { + AboutDialog dialog(this); + dialog.exec(); + }); } void MainWindow::setupToolbar() diff --git a/src/texturelab/widgets/aboutdialog.cpp b/src/texturelab/widgets/aboutdialog.cpp new file mode 100644 index 00000000..6b4feef2 --- /dev/null +++ b/src/texturelab/widgets/aboutdialog.cpp @@ -0,0 +1,132 @@ +#include "aboutdialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent) +{ + setWindowTitle("About TextureLab"); + setFixedSize(440, 320); + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + setupUI(); +} + +void AboutDialog::setupUI() +{ + setStyleSheet(R"( + QDialog { + background-color: #1e1e1e; + color: #e0e0e0; + } + QLabel { + color: #e0e0e0; + background: transparent; + } + QPushButton#closeBtn { + background-color: #3a3a3a; + color: #e0e0e0; + border: none; + border-radius: 4px; + padding: 6px 20px; + font-size: 13px; + } + QPushButton#closeBtn:hover { + background-color: #4a4a4a; + } + QPushButton#closeBtn:pressed { + background-color: #2a2a2a; + } + )"); + + auto outerLayout = new QVBoxLayout(this); + outerLayout->setContentsMargins(0, 0, 0, 0); + outerLayout->setSpacing(0); + + // Header band + auto header = new QWidget(); + header->setFixedHeight(110); + header->setStyleSheet("background: transparent;"); + + auto headerLayout = new QHBoxLayout(header); + headerLayout->setContentsMargins(28, 0, 28, 0); + headerLayout->setSpacing(20); + + auto logoLabel = new QLabel(); + QPixmap logo(":/icons/logo.png"); + if (!logo.isNull()) { + logoLabel->setPixmap( + logo.scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + } + logoLabel->setFixedSize(64, 64); + headerLayout->addWidget(logoLabel); + + auto titleBlock = new QVBoxLayout(); + titleBlock->setSpacing(4); + + auto nameLabel = new QLabel("TextureLab"); + nameLabel->setStyleSheet( + "color: #ffffff; font-size: 26px; font-weight: bold;"); + titleBlock->addWidget(nameLabel); + + auto tagLabel = new QLabel("Procedural Texture Authoring"); + tagLabel->setStyleSheet("color: #a0b4d0; font-size: 12px;"); + titleBlock->addWidget(tagLabel); + + headerLayout->addLayout(titleBlock); + headerLayout->addStretch(); + + outerLayout->addWidget(header); + + // Body + auto body = new QWidget(); + auto bodyLayout = new QVBoxLayout(body); + bodyLayout->setContentsMargins(28, 22, 28, 20); + bodyLayout->setSpacing(10); + + QString version = QCoreApplication::applicationVersion(); + auto versionLabel = new QLabel(QString("Version %1").arg(version)); + versionLabel->setStyleSheet("font-size: 13px; color: #b0b0b0;"); + bodyLayout->addWidget(versionLabel); + + auto separator = new QFrame(); + separator->setFrameShape(QFrame::HLine); + separator->setStyleSheet("color: #333333;"); + bodyLayout->addWidget(separator); + + auto descLabel = new QLabel( + "A node-based texture creation tool for game artists and developers."); + descLabel->setWordWrap(true); + descLabel->setStyleSheet("font-size: 13px; color: #c0c0c0; line-height: 1.4;"); + bodyLayout->addWidget(descLabel); + + auto linkLabel = new QLabel( + "github.com/njbrown/texturelab"); + linkLabel->setOpenExternalLinks(true); + linkLabel->setStyleSheet("font-size: 12px;"); + bodyLayout->addWidget(linkLabel); + + bodyLayout->addStretch(); + + auto footerLayout = new QHBoxLayout(); + auto copyrightLabel = new QLabel("© Nicolas Brown"); + copyrightLabel->setStyleSheet("font-size: 11px; color: #666666;"); + footerLayout->addWidget(copyrightLabel); + footerLayout->addStretch(); + + auto closeBtn = new QPushButton("Close"); + closeBtn->setObjectName("closeBtn"); + closeBtn->setDefault(true); + connect(closeBtn, &QPushButton::clicked, this, &QDialog::accept); + footerLayout->addWidget(closeBtn); + + bodyLayout->addLayout(footerLayout); + outerLayout->addWidget(body); +} diff --git a/src/texturelab/widgets/aboutdialog.h b/src/texturelab/widgets/aboutdialog.h new file mode 100644 index 00000000..161598c1 --- /dev/null +++ b/src/texturelab/widgets/aboutdialog.h @@ -0,0 +1,16 @@ +#ifndef ABOUTDIALOG_H +#define ABOUTDIALOG_H + +#include + +class AboutDialog : public QDialog { + Q_OBJECT + +public: + explicit AboutDialog(QWidget* parent = nullptr); + +private: + void setupUI(); +}; + +#endif // ABOUTDIALOG_H From bd422a02613ad8f8f7f19d7e1f25526215f209e7 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 7 Jun 2026 03:58:26 -0500 Subject: [PATCH 086/164] add prop accordion --- src/texturelab/CMakeLists.txt | 2 + .../widgets/properties/accordionwidget.cpp | 61 ++++ .../widgets/properties/accordionwidget.h | 22 ++ .../widgets/properties/propertieswidget.cpp | 269 +++++++++--------- .../widgets/properties/propertieswidget.h | 2 + 5 files changed, 216 insertions(+), 140 deletions(-) create mode 100644 src/texturelab/widgets/properties/accordionwidget.cpp create mode 100644 src/texturelab/widgets/properties/accordionwidget.h diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 9169d4c0..3537550d 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -175,6 +175,8 @@ set(PROJECT_SOURCES ./widgets/properties/propwidgets.cpp ./widgets/properties/curvepropwidget.h ./widgets/properties/curvepropwidget.cpp + ./widgets/properties/accordionwidget.h + ./widgets/properties/accordionwidget.cpp ./widgets/view2dwidget.h ./widgets/view2dwidget.cpp ./widgets/view3dwidget.h diff --git a/src/texturelab/widgets/properties/accordionwidget.cpp b/src/texturelab/widgets/properties/accordionwidget.cpp new file mode 100644 index 00000000..ca0ff62e --- /dev/null +++ b/src/texturelab/widgets/properties/accordionwidget.cpp @@ -0,0 +1,61 @@ +#include "accordionwidget.h" + +#include +#include + +AccordionWidget::AccordionWidget(const QString& title, bool startCollapsed, + QWidget* parent) + : QWidget(parent), _title(title), _collapsed(startCollapsed) +{ + auto* outerLayout = new QVBoxLayout(this); + outerLayout->setContentsMargins(0, 0, 0, 2); + outerLayout->setSpacing(0); + this->setLayout(outerLayout); + + headerButton = new QPushButton(this); + headerButton->setStyleSheet( + "QPushButton {" + " background: #333333;" + " color: #cccccc;" + " font-weight: bold;" + " font-size: 12px;" + " text-align: left;" + " padding: 5px 8px;" + " border: none;" + " border-top: 1px solid #444444;" + " border-bottom: 1px solid #444444;" + "}" + "QPushButton:hover {" + " background: #3d3d3d;" + "}"); + headerButton->setFlat(true); + headerButton->setCursor(Qt::PointingHandCursor); + outerLayout->addWidget(headerButton); + + contentWidget = new QWidget(this); + contentLayout = new QVBoxLayout(contentWidget); + contentLayout->setContentsMargins(0, 0, 0, 0); + contentLayout->setSpacing(0); + contentWidget->setLayout(contentLayout); + outerLayout->addWidget(contentWidget); + + updateHeader(); + contentWidget->setVisible(!_collapsed); + + connect(headerButton, &QPushButton::clicked, this, [this]() { + _collapsed = !_collapsed; + updateHeader(); + contentWidget->setVisible(!_collapsed); + }); +} + +void AccordionWidget::updateHeader() +{ + QString arrow = _collapsed ? " ▶ " : " ▼ "; + headerButton->setText(arrow + _title); +} + +void AccordionWidget::addWidget(QWidget* widget) +{ + contentLayout->addWidget(widget); +} diff --git a/src/texturelab/widgets/properties/accordionwidget.h b/src/texturelab/widgets/properties/accordionwidget.h new file mode 100644 index 00000000..1455094f --- /dev/null +++ b/src/texturelab/widgets/properties/accordionwidget.h @@ -0,0 +1,22 @@ +#pragma once +#include + +class QVBoxLayout; +class QPushButton; + +class AccordionWidget : public QWidget { + Q_OBJECT + + QString _title; + QPushButton* headerButton; + QWidget* contentWidget; + QVBoxLayout* contentLayout; + bool _collapsed; + + void updateHeader(); + +public: + AccordionWidget(const QString& title, bool startCollapsed = false, + QWidget* parent = nullptr); + void addWidget(QWidget* widget); +}; diff --git a/src/texturelab/widgets/properties/propertieswidget.cpp b/src/texturelab/widgets/properties/propertieswidget.cpp index f637f270..8d3d1c1a 100644 --- a/src/texturelab/widgets/properties/propertieswidget.cpp +++ b/src/texturelab/widgets/properties/propertieswidget.cpp @@ -1,6 +1,7 @@ #include "propertieswidget.h" #include "../../models.h" #include "../../props.h" +#include "accordionwidget.h" #include "curvepropwidget.h" #include "propwidgets.h" @@ -29,6 +30,118 @@ PropertiesWidget::PropertiesWidget() : QWidget() this->setLayout(layout); } +QWidget* PropertiesWidget::createPropWidget(Prop* prop, + const TextureNodePtr& node) +{ + switch (prop->type) { + case PropType::Float: { + auto widget = new FloatPropWidget(); + widget->setProp((FloatProp*)prop); + propWidgets.append(widget); + connect(widget, &FloatPropWidget::valueChanged, [=](double value) { + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + }); + return widget; + } + case PropType::Bool: { + auto widget = new BoolPropWidget(); + widget->setProp((BoolProp*)prop); + propWidgets.append(widget); + connect(widget, &BoolPropWidget::valueChanged, [=](bool value) { + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + }); + return widget; + } + case PropType::Int: { + auto widget = new IntPropWidget(); + widget->setProp((IntProp*)prop); + propWidgets.append(widget); + connect(widget, &IntPropWidget::valueChanged, [=](long value) { + node->setProp(prop->name, (int)value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, (int)value); + }); + return widget; + } + case PropType::Enum: { + auto widget = new EnumPropWidget(); + widget->setProp((EnumProp*)prop); + propWidgets.append(widget); + connect(widget, &EnumPropWidget::valueChanged, [=](int value) { + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + }); + return widget; + } + case PropType::Color: { + auto widget = new ColorPropWidget(); + widget->setProp((ColorProp*)prop); + propWidgets.append(widget); + connect(widget, &ColorPropWidget::valueChanged, + [=](const QColor& value) { + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + }); + return widget; + } + case PropType::Gradient: { + auto widget = new GradientPropWidget(); + widget->setProp((GradientProp*)prop); + propWidgets.append(widget); + connect(widget, &GradientPropWidget::valueChanged, + [=](const Gradient& value) { + node->setProp(prop->name, QVariant::fromValue(value)); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, QVariant::fromValue(value)); + }); + return widget; + } + case PropType::Image: { + auto widget = new ImagePropWidget(); + widget->setProp((ImageProp*)prop); + propWidgets.append(widget); + connect(widget, &ImagePropWidget::valueChanged, + [=](const QImage& value) { + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + }); + return widget; + } + case PropType::String: { + auto widget = new StringPropWidget(); + widget->setProp((StringProp*)prop); + propWidgets.append(widget); + connect(widget, &StringPropWidget::valueChanged, + [=](const QString& value) { + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + }); + return widget; + } + case PropType::Curve: { + auto widget = new CurvePropWidget((CurveProp*)prop); + propWidgets.append(widget); + connect(widget, &CurvePropWidget::valueChanged, + [=](const Curve& value) { + node->setProp(prop->name, QVariant::fromValue(value)); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, QVariant::fromValue(value)); + }); + return widget; + } + default: + return nullptr; + } +} + void PropertiesWidget::setSelectedNode(const TextureNodePtr& node) { qDebug() << "Displaying properties for node: " << node->title; @@ -39,156 +152,32 @@ void PropertiesWidget::setSelectedNode(const TextureNodePtr& node) auto layout = (QVBoxLayout*)this->layout(); - // add base props this->addBasePropsToLayout(); - // sort props by order + // sort all props by insertion order QList sortedProps = node->props.values(); std::sort(sortedProps.begin(), sortedProps.end(), [](Prop* a, Prop* b) { return a->order < b->order; }); - // add new props to layout + // ungrouped props first for (auto prop : sortedProps) { - switch (prop->type) { - case PropType::Float: { - auto widget = new FloatPropWidget(); - widget->setProp((FloatProp*)prop); - propWidgets.append(widget); - - connect(widget, &FloatPropWidget::valueChanged, [=](double value) { - // qDebug() << "prop" << prop->name << " changed: " << value; - // set node value - - node->setProp(prop->name, value); - project->markNodeAsDirty(node); - - emit propertyUpdated(prop->name, value); - }); - layout->addWidget(widget); - - } break; - case PropType::Bool: { - auto widget = new BoolPropWidget(); - widget->setProp((BoolProp*)prop); - propWidgets.append(widget); - - connect(widget, &BoolPropWidget::valueChanged, [=](bool value) { - node->setProp(prop->name, value); - project->markNodeAsDirty(node); - - emit propertyUpdated(prop->name, value); - }); - layout->addWidget(widget); - - } break; - case PropType::Int: { - auto widget = new IntPropWidget(); - widget->setProp((IntProp*)prop); - propWidgets.append(widget); - - connect(widget, &IntPropWidget::valueChanged, [=](int value) { - // qDebug() << "prop" << prop->name << " changed: " << value; - // set node value - - node->setProp(prop->name, value); - project->markNodeAsDirty(node); - - emit propertyUpdated(prop->name, value); - }); - layout->addWidget(widget); - - } break; - case PropType::Enum: { - auto widget = new EnumPropWidget(); - widget->setProp((EnumProp*)prop); - propWidgets.append(widget); - - connect(widget, &EnumPropWidget::valueChanged, [=](int value) { - node->setProp(prop->name, value); - project->markNodeAsDirty(node); - - emit propertyUpdated(prop->name, value); - }); - layout->addWidget(widget); - - } break; - case PropType::Color: { - auto widget = new ColorPropWidget(); - widget->setProp((ColorProp*)prop); - propWidgets.append(widget); - - connect(widget, &ColorPropWidget::valueChanged, - [=](const QColor& value) { - node->setProp(prop->name, value); - project->markNodeAsDirty(node); - - emit propertyUpdated(prop->name, value); - }); - layout->addWidget(widget); - - } break; - case PropType::Gradient: { - auto widget = new GradientPropWidget(); - widget->setProp((GradientProp*)prop); - propWidgets.append(widget); - - connect(widget, &GradientPropWidget::valueChanged, - [=](const Gradient& value) { - node->setProp(prop->name, QVariant::fromValue(value)); - project->markNodeAsDirty(node); - - emit propertyUpdated(prop->name, - QVariant::fromValue(value)); - }); - layout->addWidget(widget); - - } break; - case PropType::Image: { - auto widget = new ImagePropWidget(); - widget->setProp((ImageProp*)prop); - propWidgets.append(widget); - - connect(widget, &ImagePropWidget::valueChanged, - [=](const QImage& value) { - node->setProp(prop->name, value); - project->markNodeAsDirty(node); - - emit propertyUpdated(prop->name, value); - }); - layout->addWidget(widget); - - } break; - case PropType::String: { - auto widget = new StringPropWidget(); - widget->setProp((StringProp*)prop); - propWidgets.append(widget); - - connect(widget, &StringPropWidget::valueChanged, - [=](const QString& value) { - node->setProp(prop->name, value); - project->markNodeAsDirty(node); - - emit propertyUpdated(prop->name, value); - }); - layout->addWidget(widget); - - } break; - case PropType::Curve: { - auto widget = new CurvePropWidget((CurveProp*)prop); - propWidgets.append(widget); - - connect(widget, &CurvePropWidget::valueChanged, - [=](const Curve& value) { - node->setProp(prop->name, QVariant::fromValue(value)); - project->markNodeAsDirty(node); - - emit propertyUpdated(prop->name, - QVariant::fromValue(value)); - }); + if (prop->group != nullptr) + continue; + auto widget = createPropWidget(prop, node); + if (widget) layout->addWidget(widget); + } - } break; + // then each group as a collapsible accordion + for (auto group : node->propertyGroups) { + auto accordion = + new AccordionWidget(group->name, group->collapsed, this); + for (auto prop : group->props) { + auto widget = createPropWidget(prop, node); + if (widget) + accordion->addWidget(widget); } + layout->addWidget(accordion); } layout->addStretch(1); diff --git a/src/texturelab/widgets/properties/propertieswidget.h b/src/texturelab/widgets/properties/propertieswidget.h index a11887f0..29b9c741 100644 --- a/src/texturelab/widgets/properties/propertieswidget.h +++ b/src/texturelab/widgets/properties/propertieswidget.h @@ -12,6 +12,7 @@ typedef QSharedPointer TextureNodePtr; typedef QSharedPointer CommentPtr; typedef QSharedPointer FramePtr; +class Prop; class EnumProp; class IntProp; @@ -47,6 +48,7 @@ class PropertiesWidget : public QWidget { private: void addBasePropsToLayout(); + QWidget* createPropWidget(Prop* prop, const TextureNodePtr& node); signals: void propertyUpdated(const QString& name, const QVariant& value); From 1cd9c01c7e3999aa4a00f725bc447c3ef8f889fc Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 7 Jun 2026 04:37:59 -0500 Subject: [PATCH 087/164] display port names --- src/nodegraph/graph/scene.cpp | 170 +++++++++++++++++++++++++--------- src/nodegraph/graph/scene.h | 3 + src/nodegraph/nodegraph.cpp | 21 +++++ src/nodegraph/nodegraph.h | 3 + 4 files changed, 152 insertions(+), 45 deletions(-) diff --git a/src/nodegraph/graph/scene.cpp b/src/nodegraph/graph/scene.cpp index e6c5c1a1..90c1dd41 100644 --- a/src/nodegraph/graph/scene.cpp +++ b/src/nodegraph/graph/scene.cpp @@ -5,8 +5,8 @@ #include #include #include -#include #include +#include #include #include #include @@ -25,14 +25,16 @@ bool Node::glInitialized = false; void Node::initializeGL() { - if (glInitialized) return; - + if (glInitialized) + return; + QOpenGLContext* ctx = QOpenGLContext::currentContext(); - if (!ctx) return; - + if (!ctx) + return; + // Create shader program shaderProgram = new QOpenGLShaderProgram(); - + const char* vertexShaderSource = R"( #version 150 in vec2 position; @@ -44,7 +46,7 @@ void Node::initializeGL() vTexCoord = texCoord; } )"; - + const char* fragmentShaderSource = R"( #version 150 in vec2 vTexCoord; @@ -60,41 +62,44 @@ void Node::initializeGL() fragColor = vec4(mix(bg, texColor.rgb, texColor.a), 1.0); } )"; - - shaderProgram->addShaderFromSourceCode(QOpenGLShader::Vertex, vertexShaderSource); - shaderProgram->addShaderFromSourceCode(QOpenGLShader::Fragment, fragmentShaderSource); + + shaderProgram->addShaderFromSourceCode(QOpenGLShader::Vertex, + vertexShaderSource); + shaderProgram->addShaderFromSourceCode(QOpenGLShader::Fragment, + fragmentShaderSource); shaderProgram->link(); - + // Create VAO and VBO vao = new QOpenGLVertexArrayObject(); vao->create(); - + vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); vbo->create(); vbo->setUsagePattern(QOpenGLBuffer::DynamicDraw); - + glInitialized = true; } void Node::cleanupGL() { - if (!glInitialized) return; - + if (!glInitialized) + return; + delete shaderProgram; shaderProgram = nullptr; - + if (vbo) { vbo->destroy(); delete vbo; vbo = nullptr; } - + if (vao) { vao->destroy(); delete vao; vao = nullptr; } - + glInitialized = false; } @@ -222,6 +227,7 @@ Node::Node() width = NODE_WIDTH; height = NODE_HEIGHT; isHovered = false; + showingSocketNames = false; defaultBorderColor = QColor(0, 0, 0); highlightBorderColor = QColor(0, 0, 0); @@ -280,6 +286,14 @@ Node::Node() NodePtr Node::create() { return NodePtr(new Node()); } +void Node::setShowSocketNames(bool show) +{ + if (showingSocketNames == show) + return; + showingSocketNames = show; + update(); +} + void Node::setCenter(float x, float y) { setPos(x - NODE_WIDTH / 2.0f, y - NODE_HEIGHT / 2.0f); @@ -287,7 +301,8 @@ void Node::setCenter(float x, float y) QPointF Node::getCenter() const { - return QPointF(pos().x() + NODE_WIDTH / 2.0f, pos().y() + NODE_HEIGHT / 2.0f); + return QPointF(pos().x() + NODE_WIDTH / 2.0f, + pos().y() + NODE_HEIGHT / 2.0f); } void Node::setName(QString name) @@ -315,7 +330,8 @@ void Node::setChannel(QString ch) this->channel = ch; if (ch.isEmpty()) { channelText->hide(); - } else { + } + else { channelText->setPlainText(ch.toUpper()); QFontMetrics fm(channelText->font()); int textW = fm.horizontalAdvance(ch.toUpper()); @@ -508,7 +524,8 @@ void Node::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, cp.fillRect(0, 0, 8, 8, QColor(0x80, 0x80, 0x80)); cp.fillRect(8, 8, 8, 8, QColor(0x80, 0x80, 0x80)); } - painter->fillRect(QRect(0, 0, nodeWidth, nodeHeight), QBrush(checkerTile)); + painter->fillRect(QRect(0, 0, nodeWidth, nodeHeight), + QBrush(checkerTile)); painter->drawPixmap(QRect(0, 0, nodeWidth, nodeHeight), thumbnail); } @@ -519,74 +536,97 @@ void Node::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, // Initialize OpenGL resources if needed initializeGL(); - + if (glInitialized && shaderProgram && vao && vbo) { QOpenGLFunctions* f = QOpenGLContext::currentContext()->functions(); - + // Get the current viewport and create orthographic projection GLint viewport[4]; f->glGetIntegerv(GL_VIEWPORT, viewport); - + // Create orthographic projection matrix QTransform transform = painter->combinedTransform(); QMatrix4x4 projectionMatrix; projectionMatrix.ortho(0, viewport[2], viewport[3], 0, -1, 1); - - // Build vertex data - transform scene coordinates to device coordinates + + // Build vertex data - transform scene coordinates to device + // coordinates QPointF p0 = transform.map(QPointF(0, 0)); QPointF p1 = transform.map(QPointF(100, 0)); QPointF p2 = transform.map(QPointF(100, 100)); QPointF p3 = transform.map(QPointF(0, 100)); - + // Two triangles for a quad: position (x,y) + texcoord (u,v) GLfloat vertices[] = { // Triangle 1 - (GLfloat)p0.x(), (GLfloat)p0.y(), 0.0f, 1.0f, - (GLfloat)p1.x(), (GLfloat)p1.y(), 1.0f, 1.0f, - (GLfloat)p2.x(), (GLfloat)p2.y(), 1.0f, 0.0f, + (GLfloat)p0.x(), + (GLfloat)p0.y(), + 0.0f, + 1.0f, + (GLfloat)p1.x(), + (GLfloat)p1.y(), + 1.0f, + 1.0f, + (GLfloat)p2.x(), + (GLfloat)p2.y(), + 1.0f, + 0.0f, // Triangle 2 - (GLfloat)p0.x(), (GLfloat)p0.y(), 0.0f, 1.0f, - (GLfloat)p2.x(), (GLfloat)p2.y(), 1.0f, 0.0f, - (GLfloat)p3.x(), (GLfloat)p3.y(), 0.0f, 0.0f, + (GLfloat)p0.x(), + (GLfloat)p0.y(), + 0.0f, + 1.0f, + (GLfloat)p2.x(), + (GLfloat)p2.y(), + 1.0f, + 0.0f, + (GLfloat)p3.x(), + (GLfloat)p3.y(), + 0.0f, + 0.0f, }; - + // Setup state f->glDisable(GL_BLEND); f->glDisable(GL_DEPTH_TEST); - + // Bind shader shaderProgram->bind(); - shaderProgram->setUniformValue("projectionMatrix", projectionMatrix); + shaderProgram->setUniformValue("projectionMatrix", + projectionMatrix); shaderProgram->setUniformValue("textureSampler", 0); - + // Bind texture f->glActiveTexture(GL_TEXTURE0); f->glBindTexture(GL_TEXTURE_2D, texId); - + // Setup VAO and VBO vao->bind(); vbo->bind(); vbo->allocate(vertices, sizeof(vertices)); - + // Setup vertex attributes int positionLoc = shaderProgram->attributeLocation("position"); int texCoordLoc = shaderProgram->attributeLocation("texCoord"); - + shaderProgram->enableAttributeArray(positionLoc); shaderProgram->enableAttributeArray(texCoordLoc); - shaderProgram->setAttributeBuffer(positionLoc, GL_FLOAT, 0, 2, 4 * sizeof(GLfloat)); - shaderProgram->setAttributeBuffer(texCoordLoc, GL_FLOAT, 2 * sizeof(GLfloat), 2, 4 * sizeof(GLfloat)); - + shaderProgram->setAttributeBuffer(positionLoc, GL_FLOAT, 0, 2, + 4 * sizeof(GLfloat)); + shaderProgram->setAttributeBuffer(texCoordLoc, GL_FLOAT, + 2 * sizeof(GLfloat), 2, + 4 * sizeof(GLfloat)); + // Draw f->glDrawArrays(GL_TRIANGLES, 0, 6); - + // Cleanup shaderProgram->disableAttributeArray(positionLoc); shaderProgram->disableAttributeArray(texCoordLoc); vbo->release(); vao->release(); shaderProgram->release(); - + f->glEnable(GL_BLEND); } @@ -609,6 +649,46 @@ void Node::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, painter->setPen(QPen(borderColor, 3)); painter->drawRoundedRect(rect, titleRadius, titleRadius); + // socket name labels — shown on hover or when cursor is nearby during drag + if (isHovered || showingSocketNames) { + painter->save(); + painter->setRenderHint(QPainter::TextAntialiasing); + + QFont labelFont = painter->font(); + labelFont.setPixelSize(10); + painter->setFont(labelFont); + + QFontMetrics fm(labelFont); + const int labelH = 14; + const int pad = 3; + const int portRadius = 7; + const int gap = 4; + + auto drawLabel = [&](const QString& labelName, QPointF portPos, + bool isIn) { + int textW = fm.horizontalAdvance(labelName); + int rectW = textW + pad * 2; + qreal x = isIn ? portPos.x() + portRadius + gap + : portPos.x() - portRadius - gap - rectW; + qreal y = portPos.y() - labelH / 2.0; + + QRectF bgRect(x, y, rectW, labelH); + painter->setPen(Qt::NoPen); + painter->setBrush(QColor(0, 0, 0, 160)); + painter->drawRoundedRect(bgRect, 3, 3); + + painter->setPen(QColor(255, 255, 255, 220)); + painter->drawText(bgRect, Qt::AlignCenter, labelName); + }; + + for (auto& port : inPorts) + drawLabel(port->name, port->pos(), true); + + // for (auto& port : outPorts) + // drawLabel(port->name, port->pos(), false); + + painter->restore(); + } } Node::~Node() diff --git a/src/nodegraph/graph/scene.h b/src/nodegraph/graph/scene.h index ee10ae16..11449c4c 100644 --- a/src/nodegraph/graph/scene.h +++ b/src/nodegraph/graph/scene.h @@ -87,6 +87,7 @@ class Node : public QGraphicsObject, public QEnableSharedFromThis { QPixmap thumbnail; bool isHovered; + bool showingSocketNames; // bool isSelected; QColor defaultBorderColor; @@ -121,6 +122,8 @@ class Node : public QGraphicsObject, public QEnableSharedFromThis { QPointF getCenter() const; void setThumbnail(const QPixmap& pixmap); + void setShowSocketNames(bool show); + void addInPort(QString name); void addOutPort(QString name); diff --git a/src/nodegraph/nodegraph.cpp b/src/nodegraph/nodegraph.cpp index 2cdc0b59..64d2e3c0 100644 --- a/src/nodegraph/nodegraph.cpp +++ b/src/nodegraph/nodegraph.cpp @@ -391,6 +391,22 @@ bool NodeGraph::sceneMouseMoveEvent(QGraphicsSceneMouseEvent* event) activeCon->pos2 = scenePos; } activeCon->updatePathFromPositions(); + + // show socket names on nodes within proximity + for (auto node : _nodesWithSocketNamesShown) + node->setShowSocketNames(false); + _nodesWithSocketNamesShown.clear(); + + for (auto& nodePtr : _scene->nodes) { + auto node = nodePtr.data(); + QPointF center = node->getCenter(); + qreal dx = center.x() - scenePos.x(); + qreal dy = center.y() - scenePos.y(); + if (dx * dx + dy * dy < SOCKET_LABEL_RADIUS * SOCKET_LABEL_RADIUS) { + node->setShowSocketNames(true); + _nodesWithSocketNamesShown.append(node); + } + } } return false; @@ -464,6 +480,11 @@ bool NodeGraph::sceneMouseReleaseEvent(QGraphicsSceneMouseEvent* event) } } + // clear proximity socket labels + for (auto node : _nodesWithSocketNamesShown) + node->setShowSocketNames(false); + _nodesWithSocketNamesShown.clear(); + // remove from scene _scene->removeItem(activeCon.data()); activeCon.clear(); diff --git a/src/nodegraph/nodegraph.h b/src/nodegraph/nodegraph.h index 7ad294c6..841de87c 100644 --- a/src/nodegraph/nodegraph.h +++ b/src/nodegraph/nodegraph.h @@ -100,6 +100,8 @@ class NodeGraph : public QGraphicsView { void handleSelectionChange(); private: + static constexpr float SOCKET_LABEL_RADIUS = 150.0f; + QPointF _clickPos; ScenePtr _scene; MouseButtonStates mbStates; @@ -107,6 +109,7 @@ class NodeGraph : public QGraphicsView { QList nodes; QList cons; + QList _nodesWithSocketNamesShown; signals: void connectionAdded(ConnectionPtr con); From 0fdf57fdcd9fc88c1115d37317c20ef75fee266e Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Tue, 9 Jun 2026 22:42:43 -0500 Subject: [PATCH 088/164] add undo/redo --- src/nodegraph/nodegraph.cpp | 56 +- src/nodegraph/nodegraph.h | 13 + src/texturelab/CMakeLists.txt | 28 + src/texturelab/mainwindow.cpp | 148 ++- src/texturelab/mainwindow.h | 10 + src/texturelab/undo/addcommentcommand.cpp | 36 + src/texturelab/undo/addcommentcommand.h | 27 + src/texturelab/undo/addconnectioncommand.cpp | 65 ++ src/texturelab/undo/addconnectioncommand.h | 34 + src/texturelab/undo/addframecommand.cpp | 36 + src/texturelab/undo/addframecommand.h | 27 + src/texturelab/undo/addnodecommand.cpp | 69 ++ src/texturelab/undo/addnodecommand.h | 32 + src/texturelab/undo/deleteitemscommand.cpp | 202 ++++ src/texturelab/undo/deleteitemscommand.h | 59 ++ src/texturelab/undo/editcommentcommand.cpp | 44 + src/texturelab/undo/editcommentcommand.h | 33 + src/texturelab/undo/editframecommand.cpp | 48 + src/texturelab/undo/editframecommand.h | 35 + src/texturelab/undo/moveitemscommand.cpp | 45 + src/texturelab/undo/moveitemscommand.h | 34 + src/texturelab/undo/pastecommand.cpp | 119 +++ src/texturelab/undo/pastecommand.h | 37 + src/texturelab/undo/propertychangecommand.cpp | 49 + src/texturelab/undo/propertychangecommand.h | 38 + .../undo/randomseedchangecommand.cpp | 36 + src/texturelab/undo/randomseedchangecommand.h | 25 + .../undo/removeconnectioncommand.cpp | 61 ++ src/texturelab/undo/removeconnectioncommand.h | 34 + .../undo/texturechannelassigncommand.cpp | 36 + .../undo/texturechannelassigncommand.h | 26 + src/texturelab/undo/undocommandids.h | 8 + src/texturelab/undo/undocommands.cpp | 871 ++++++++++++++++++ src/texturelab/undo/undocommands.h | 16 + src/texturelab/widgets/graphwidget.cpp | 420 +++++---- src/texturelab/widgets/graphwidget.h | 3 + .../widgets/properties/propertieswidget.cpp | 143 ++- .../widgets/properties/propertieswidget.h | 9 + 38 files changed, 2711 insertions(+), 301 deletions(-) create mode 100644 src/texturelab/undo/addcommentcommand.cpp create mode 100644 src/texturelab/undo/addcommentcommand.h create mode 100644 src/texturelab/undo/addconnectioncommand.cpp create mode 100644 src/texturelab/undo/addconnectioncommand.h create mode 100644 src/texturelab/undo/addframecommand.cpp create mode 100644 src/texturelab/undo/addframecommand.h create mode 100644 src/texturelab/undo/addnodecommand.cpp create mode 100644 src/texturelab/undo/addnodecommand.h create mode 100644 src/texturelab/undo/deleteitemscommand.cpp create mode 100644 src/texturelab/undo/deleteitemscommand.h create mode 100644 src/texturelab/undo/editcommentcommand.cpp create mode 100644 src/texturelab/undo/editcommentcommand.h create mode 100644 src/texturelab/undo/editframecommand.cpp create mode 100644 src/texturelab/undo/editframecommand.h create mode 100644 src/texturelab/undo/moveitemscommand.cpp create mode 100644 src/texturelab/undo/moveitemscommand.h create mode 100644 src/texturelab/undo/pastecommand.cpp create mode 100644 src/texturelab/undo/pastecommand.h create mode 100644 src/texturelab/undo/propertychangecommand.cpp create mode 100644 src/texturelab/undo/propertychangecommand.h create mode 100644 src/texturelab/undo/randomseedchangecommand.cpp create mode 100644 src/texturelab/undo/randomseedchangecommand.h create mode 100644 src/texturelab/undo/removeconnectioncommand.cpp create mode 100644 src/texturelab/undo/removeconnectioncommand.h create mode 100644 src/texturelab/undo/texturechannelassigncommand.cpp create mode 100644 src/texturelab/undo/texturechannelassigncommand.h create mode 100644 src/texturelab/undo/undocommandids.h create mode 100644 src/texturelab/undo/undocommands.cpp create mode 100644 src/texturelab/undo/undocommands.h diff --git a/src/nodegraph/nodegraph.cpp b/src/nodegraph/nodegraph.cpp index 64d2e3c0..dfffa61e 100644 --- a/src/nodegraph/nodegraph.cpp +++ b/src/nodegraph/nodegraph.cpp @@ -132,27 +132,24 @@ void NodeGraph::scaleDown() void NodeGraph::keyPressEvent(QKeyEvent* event) { if (event->key() == Qt::Key_Delete) { - auto items = this->_scene->selectedItems(); - for (auto item : items) { - if (item->type() == (int)SceneItemType::Node) { - auto node = qgraphicsitem_cast(item); - auto nodePtr = node->sharedFromThis(); - this->_scene->removeNode(nodePtr); - emit nodeRemoved(nodePtr); - } - else if (item->type() == (int)SceneItemType::Frame) { - auto frame = qgraphicsitem_cast(item); - this->_scene->removeFrame(frame->sharedFromThis()); - } - else if (item->type() == (int)SceneItemType::Comment) { - auto comment = qgraphicsitem_cast(item); - this->_scene->removeComment(comment->sharedFromThis()); - } + QList selectedNodes; + QList selectedFrames; + QList selectedComments; + + for (auto item : this->_scene->selectedItems()) { + if (item->type() == (int)SceneItemType::Node) + selectedNodes.append(qgraphicsitem_cast(item)->sharedFromThis()); + else if (item->type() == (int)SceneItemType::Frame) + selectedFrames.append(qgraphicsitem_cast(item)->sharedFromThis()); + else if (item->type() == (int)SceneItemType::Comment) + selectedComments.append(qgraphicsitem_cast(item)->sharedFromThis()); } + + if (!selectedNodes.isEmpty() || !selectedFrames.isEmpty() || !selectedComments.isEmpty()) + emit deleteRequested(selectedNodes, selectedFrames, selectedComments); } QGraphicsView::keyPressEvent(event); - this->invalidateScene(QRect(-1000, -1000, 1000, 1000)); } @@ -315,6 +312,13 @@ bool NodeGraph::sceneMousePressEvent(QGraphicsSceneMouseEvent* event) if (mbStates.left) { auto scenePos = event->scenePos(); auto rawPort = this->getPortAtScenePos(scenePos.x(), scenePos.y()); + if (!rawPort) { + // Record node positions for move-tracking (no port drag starting) + _preDragPositions.clear(); + for (auto& node : _scene->nodes) + _preDragPositions[node->id()] = node->getCenter(); + _trackingMove = true; + } if (rawPort) { // auto port = rawPort->node->getPortById(rawPort->id()); // gotta cast to get the non-const version @@ -490,6 +494,24 @@ bool NodeGraph::sceneMouseReleaseEvent(QGraphicsSceneMouseEvent* event) activeCon.clear(); } + // Emit move command if nodes changed position + if (_trackingMove && !activeCon) { + QMap newPositions; + bool moved = false; + for (auto it = _preDragPositions.begin(); it != _preDragPositions.end(); ++it) { + auto node = _scene->nodes.value(it.key()); + if (!node) + continue; + QPointF newPos = node->getCenter(); + newPositions[it.key()] = newPos; + if (newPos != it.value()) + moved = true; + } + if (moved) + emit itemsMoveFinished(_preDragPositions, newPositions); + } + _trackingMove = false; + // important to reset drag! this->setDragMode(QGraphicsView::RubberBandDrag); return false; diff --git a/src/nodegraph/nodegraph.h b/src/nodegraph/nodegraph.h index 841de87c..c2680b19 100644 --- a/src/nodegraph/nodegraph.h +++ b/src/nodegraph/nodegraph.h @@ -111,12 +111,25 @@ class NodeGraph : public QGraphicsView { QList cons; QList _nodesWithSocketNamesShown; + // Position tracking for move commands + bool _trackingMove = false; + QMap _preDragPositions; + signals: void connectionAdded(ConnectionPtr con); void connectionRemoved(ConnectionPtr con); void nodeAdded(NodePtr node); void nodeRemoved(NodePtr node); + // Emitted instead of directly deleting; GraphWidget pushes the undo command + void deleteRequested(QList nodes, + QList frames, + QList comments); + + // Emitted on mouse-release when selected nodes moved; oldPos/newPos keyed by node id + void itemsMoveFinished(QMap oldPositions, + QMap newPositions); + // null nodeptr means no active node selected void nodeSelectionChanged(const NodePtr& node); void nodeDoubleClicked(const NodePtr& node); diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 3537550d..479fd520 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -181,6 +181,34 @@ set(PROJECT_SOURCES ./widgets/view2dwidget.cpp ./widgets/view3dwidget.h ./widgets/view3dwidget.cpp + ./undo/undocommands.h + ./undo/undocommandids.h + ./undo/addnodecommand.h + ./undo/addnodecommand.cpp + ./undo/deleteitemscommand.h + ./undo/deleteitemscommand.cpp + ./undo/addconnectioncommand.h + ./undo/addconnectioncommand.cpp + ./undo/removeconnectioncommand.h + ./undo/removeconnectioncommand.cpp + ./undo/moveitemscommand.h + ./undo/moveitemscommand.cpp + ./undo/propertychangecommand.h + ./undo/propertychangecommand.cpp + ./undo/randomseedchangecommand.h + ./undo/randomseedchangecommand.cpp + ./undo/addframecommand.h + ./undo/addframecommand.cpp + ./undo/addcommentcommand.h + ./undo/addcommentcommand.cpp + ./undo/editframecommand.h + ./undo/editframecommand.cpp + ./undo/editcommentcommand.h + ./undo/editcommentcommand.cpp + ./undo/pastecommand.h + ./undo/pastecommand.cpp + ./undo/texturechannelassigncommand.h + ./undo/texturechannelassigncommand.cpp ./widgets/aboutdialog.h ./widgets/aboutdialog.cpp ./widgets/exportdialog.h diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 9dbadb49..cb45f980 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -27,6 +27,7 @@ #include "DockSplitter.h" #include "exporter.h" +#include "undo/undocommands.h" #include "widgets/aboutdialog.h" #include "widgets/exportdialog.h" #include "widgets/graphwidget.h" @@ -48,6 +49,9 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { resize(1280, 720); + undoStack = new QUndoStack(this); + connect(undoStack, &QUndoStack::cleanChanged, this, &MainWindow::onCleanChanged); + this->setupMenus(); this->setupToolbar(); @@ -140,36 +144,41 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) connect(this->propWidget, &PropertiesWidget::textureChannelUpdated, [this](const TextureChannel& name, const TextureNodePtr& node) { - if (this->renderer && !!this->project) { - this->renderer->update(); - } - - if (!!this->project) { - if (name == TextureChannel::None) { - // Find and remove any channel that points to this node - auto it = this->project->textureChannels.begin(); - while (it != this->project->textureChannels.end()) { - if (it.value() == node->id) { - it = this->project->textureChannels.erase(it); - } - else { - ++it; - } - } - } - else { - - this->project->textureChannels[name] = node->id; - } + if (!this->project) + return; - // assign channel to viewer - // do this crudely by just reassigning all node textures + auto syncViewer = [this]() { this->passTextureChannelsToViewer3D(); this->syncChannelLabelsToScene(); + this->view3DWidget->reRender(); + if (this->renderer) + this->renderer->update(); + }; + + if (name == TextureChannel::None) { + // Unassign this node from whichever channel it's in + for (auto ch : this->project->textureChannels.keys()) { + if (this->project->textureChannels[ch] == node->id) { + QString oldNodeId = node->id; + // Apply immediately, then push command (first-redo no-op) + this->project->textureChannels.remove(ch); + syncViewer(); + if (undoStack) + undoStack->push(new TextureChannelAssignCommand( + this->project, ch, oldNodeId, "", syncViewer)); + break; + } + } + } + else { + QString oldNodeId = this->project->textureChannels.value(name, ""); + // Apply immediately, then push command (first-redo no-op) + this->project->textureChannels[name] = node->id; + syncViewer(); + if (undoStack) + undoStack->push(new TextureChannelAssignCommand( + this->project, name, oldNodeId, node->id, syncViewer)); } - - // this->view3DWidget->update(); - this->view3DWidget->reRender(); }); // set default empty project @@ -291,6 +300,7 @@ void MainWindow::setProject(TextureProjectPtr project) this->propWidget->clearSelection(); this->propWidget->setProject(project); + this->propWidget->setScene(this->graphWidget->scene); renderer = new TextureRenderer(); renderer->setProject(project); @@ -349,8 +359,8 @@ void MainWindow::setProject(TextureProjectPtr project) renderer->update(); - // Update window title with project name setWindowTitle(project->name + " - TextureLab"); + undoStack->clear(); } void MainWindow::setupMenus() @@ -371,8 +381,12 @@ void MainWindow::setupMenus() fileMenu->addAction("Edit", []() {}); auto editMenu = this->menuBar()->addMenu("Edit"); - editMenu->addAction("Undo", []() {}); - editMenu->addAction("Redo", []() {}); + auto undoAction = undoStack->createUndoAction(this, tr("Undo")); + undoAction->setShortcut(QKeySequence::Undo); + editMenu->addAction(undoAction); + auto redoAction = undoStack->createRedoAction(this, tr("Redo")); + redoAction->setShortcut(QKeySequence::Redo); + editMenu->addAction(redoAction); editMenu->addAction("Cut", [=]() { graphWidget->executeCut(); }); editMenu->addAction("Copy", [=]() { graphWidget->executeCopy(); }); editMenu->addAction("Paste", [=]() { graphWidget->executePaste(); }); @@ -397,16 +411,12 @@ void MainWindow::setupMenus() displayName.replace(".texture", ""); examplesMenu->addAction(displayName, [this, example]() { - QString examplePath = ":examples/" + example; - - // Load the example project - auto project = Project::loadTexture(examplePath); - - // Set project name from filename + if (!promptSaveIfDirty()) + return; + auto project = Project::loadTexture(":examples/" + example); QString projectName = example; projectName.replace(".texture", ""); project->name = projectName; - setProject(project); }); } @@ -426,9 +436,9 @@ void MainWindow::setupToolbar() QWidget* spacer = new QWidget(); spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - // undo redo - toolBar->addAction("Undo"); - toolBar->addAction("Redo"); + // undo redo — reuse the same actions wired to the stack + toolBar->addAction(undoStack->createUndoAction(this)); + toolBar->addAction(undoStack->createRedoAction(this)); // spacer toolBar->addWidget(spacer); @@ -489,6 +499,7 @@ void MainWindow::setupDocks() // graph goes in the center this->graphWidget = new GraphWidget(); + this->graphWidget->setUndoStack(undoStack); this->view2DWidget = new View2DWidget(); this->view3DWidget = new View3DWidget(); @@ -498,6 +509,7 @@ void MainWindow::setupDocks() this->view2DWidget, graphArea); this->propWidget = new PropertiesWidget(); + this->propWidget->setUndoStack(undoStack); auto rightArea = addDock("Properties", ads::RightDockWidgetArea, this->propWidget, graphArea); @@ -531,12 +543,14 @@ ads::CDockAreaWidget* MainWindow::addDock(const QString& title, void MainWindow::openProject() { + if (!promptSaveIfDirty()) + return; + auto filePath = QFileDialog::getOpenFileName(this, "Open Texture File", "", "Texturelab File (*.texture)"); - if (filePath.isNull() || filePath.isEmpty()) { + if (filePath.isNull() || filePath.isEmpty()) return; - } auto project = Project::loadTexture(filePath); @@ -548,7 +562,12 @@ void MainWindow::openProject() addToRecentFiles(filePath); } -void MainWindow::newProject() { setProject(TextureProject::createEmpty()); } +void MainWindow::newProject() +{ + if (!promptSaveIfDirty()) + return; + setProject(TextureProject::createEmpty()); +} void MainWindow::saveProject() { @@ -572,6 +591,7 @@ void MainWindow::saveProject() file.open(QIODevice::WriteOnly); file.write(Project::saveTexture(project)); file.close(); + undoStack->setClean(); addToRecentFiles(project->filePath); } @@ -580,9 +600,8 @@ void MainWindow::saveProjectAs() QString filePath = QFileDialog::getSaveFileName( this, "Save Texture As...", QString(), "Texturelab File (*.texture)"); - if (filePath.isNull() || filePath.isEmpty()) { + if (filePath.isNull() || filePath.isEmpty()) return; - } if (!filePath.endsWith(".texture", Qt::CaseInsensitive)) filePath += ".texture"; @@ -591,7 +610,6 @@ void MainWindow::saveProjectAs() QFileInfo fileInfo(filePath); project->name = fileInfo.baseName(); - setWindowTitle(project->name + " - TextureLab"); graphWidget->syncPositionsToModel(); @@ -599,6 +617,8 @@ void MainWindow::saveProjectAs() file.open(QIODevice::WriteOnly); file.write(Project::saveTexture(project)); file.close(); + undoStack->setClean(); + setWindowTitle(project->name + " - TextureLab"); addToRecentFiles(project->filePath); } @@ -789,6 +809,8 @@ void MainWindow::updateRecentFilesMenu() QFileInfo info(filePath); auto action = recentFilesMenu->addAction(info.fileName(), [this, filePath]() { + if (!promptSaveIfDirty()) + return; auto project = Project::loadTexture(filePath); QFileInfo fileInfo(filePath); project->name = fileInfo.baseName(); @@ -807,9 +829,43 @@ void MainWindow::updateRecentFilesMenu() [this]() { QSettings().remove("recentFiles"); }); } +void MainWindow::onCleanChanged(bool clean) +{ + if (!project) + return; + QString title = project->name + " - TextureLab"; + setWindowTitle(clean ? title : "*" + title); +} + +bool MainWindow::promptSaveIfDirty() +{ + if (undoStack->isClean()) + return true; + + QString name = project ? project->name : "Untitled"; + auto choice = QMessageBox::question( + this, "Unsaved Changes", + QString("Save changes to \"%1\" before continuing?").arg(name), + QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel); + + if (choice == QMessageBox::Save) { + saveProject(); + return undoStack->isClean(); // false if save was cancelled + } + return choice == QMessageBox::Discard; +} + +void MainWindow::closeEvent(QCloseEvent* event) +{ + if (!promptSaveIfDirty()) { + event->ignore(); + return; + } + event->accept(); +} + MainWindow::~MainWindow() { - // Clean up renderer if (this->renderer) { delete this->renderer; this->renderer = nullptr; diff --git a/src/texturelab/mainwindow.h b/src/texturelab/mainwindow.h index 226d41a7..b3e01452 100644 --- a/src/texturelab/mainwindow.h +++ b/src/texturelab/mainwindow.h @@ -2,11 +2,13 @@ #define MAINWINDOW_H #include "DockManager.h" +#include #include #include #include #include #include +#include class GraphWidget; class LibraryWidget; @@ -33,6 +35,7 @@ class MainWindow : public QMainWindow { void setupToolbar(); void setupMenus(); void setupDocks(); + void closeEvent(QCloseEvent* event) override; // menu callbacks void openProject(); @@ -51,6 +54,11 @@ class MainWindow : public QMainWindow { void addToRecentFiles(const QString& filePath); void updateRecentFilesMenu(); + void onCleanChanged(bool clean); + + // Returns false if the user cancelled a "save changes?" dialog. + bool promptSaveIfDirty(); + ads::CDockAreaWidget* addDock(const QString& title, ads::DockWidgetArea area, QWidget* widget, ads::CDockAreaWidget* areaWidget); @@ -58,6 +66,8 @@ class MainWindow : public QMainWindow { private: static constexpr int MaxRecentFiles = 10; + QUndoStack* undoStack; + ads::CDockManager* dockManager; QMenu* recentFilesMenu; QToolBar* toolBar; diff --git a/src/texturelab/undo/addcommentcommand.cpp b/src/texturelab/undo/addcommentcommand.cpp new file mode 100644 index 00000000..40fd6794 --- /dev/null +++ b/src/texturelab/undo/addcommentcommand.cpp @@ -0,0 +1,36 @@ +#include "addcommentcommand.h" + +#include "graph/comment.h" +#include "graph/scene.h" + +AddCommentCommand::AddCommentCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QString& commentId, + QVector2D pos) + : QUndoCommand("Add Comment") + , _project(project) + , _scene(scene) + , _commentId(commentId) + , _pos(pos) +{} + +void AddCommentCommand::redo() +{ + auto modelComment = CommentPtr(new Comment()); + modelComment->id = _commentId; + modelComment->pos = _pos; + _project->comments[_commentId] = modelComment; + + auto gcomment = nodegraph::Comment::create(); + gcomment->setId(_commentId); + gcomment->setPos(_pos.x(), _pos.y()); + _scene->addComment(gcomment); +} + +void AddCommentCommand::undo() +{ + auto gcomment = _scene->getCommentById(_commentId); + if (gcomment) + _scene->removeComment(gcomment); + _project->comments.remove(_commentId); +} diff --git a/src/texturelab/undo/addcommentcommand.h b/src/texturelab/undo/addcommentcommand.h new file mode 100644 index 00000000..e5496460 --- /dev/null +++ b/src/texturelab/undo/addcommentcommand.h @@ -0,0 +1,27 @@ +#pragma once + +#include "../models.h" +#include +#include + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +class AddCommentCommand : public QUndoCommand { +public: + AddCommentCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QString& commentId, + QVector2D pos); + + void redo() override; + void undo() override; + +private: + TextureProjectPtr _project; + nodegraph::ScenePtr _scene; + QString _commentId; + QVector2D _pos; +}; diff --git a/src/texturelab/undo/addconnectioncommand.cpp b/src/texturelab/undo/addconnectioncommand.cpp new file mode 100644 index 00000000..b886af4b --- /dev/null +++ b/src/texturelab/undo/addconnectioncommand.cpp @@ -0,0 +1,65 @@ +#include "addconnectioncommand.h" + +#include "../graphics/texturerenderer.h" +#include "graph/scene.h" + +AddConnectionCommand::AddConnectionCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& leftNodeId, + const QString& leftOutput, + const QString& rightNodeId, + const QString& rightInput) + : QUndoCommand("Connect Nodes") + , _project(project) + , _scene(scene) + , _renderer(renderer) + , _leftNodeId(leftNodeId) + , _leftOutput(leftOutput) + , _rightNodeId(rightNodeId) + , _rightInput(rightInput) +{} + +void AddConnectionCommand::redo() +{ + if (_firstRedo) { + // Scene already has the connection — just add to project model + auto left = _project->getNodeById(_leftNodeId); + auto right = _project->getNodeById(_rightNodeId); + if (left && right) { + _project->addConnection(left, right, _rightInput); + right->isDirty = true; + } + _firstRedo = false; + } else { + auto leftG = _scene->getNodeById(_leftNodeId); + auto rightG = _scene->getNodeById(_rightNodeId); + if (leftG && rightG) + _scene->connectNodes(leftG, _leftOutput, rightG, _rightInput); + + auto left = _project->getNodeById(_leftNodeId); + auto right = _project->getNodeById(_rightNodeId); + if (left && right) { + _project->addConnection(left, right, _rightInput); + right->isDirty = true; + } + } + if (_renderer) + _renderer->update(); +} + +void AddConnectionCommand::undo() +{ + auto rightG = _scene->getNodeById(_rightNodeId); + if (rightG) { + auto port = rightG->getInPortByName(_rightInput); + if (port && !port->connections.isEmpty()) + _scene->removeConnection(port->connections.first()); + } + auto right = _project->getNodeById(_rightNodeId); + if (right) + right->isDirty = true; + _project->removeConnection(_leftNodeId, _rightNodeId, _rightInput); + if (_renderer) + _renderer->update(); +} diff --git a/src/texturelab/undo/addconnectioncommand.h b/src/texturelab/undo/addconnectioncommand.h new file mode 100644 index 00000000..1535f40a --- /dev/null +++ b/src/texturelab/undo/addconnectioncommand.h @@ -0,0 +1,34 @@ +#pragma once + +#include "../models.h" +#include + +class TextureRenderer; + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +// Records a connection drawn in the scene. First redo is a no-op for the scene +// (already created); subsequent redos recreate it. +class AddConnectionCommand : public QUndoCommand { +public: + AddConnectionCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& leftNodeId, + const QString& leftOutput, + const QString& rightNodeId, + const QString& rightInput); + + void redo() override; + void undo() override; + +private: + TextureProjectPtr _project; + nodegraph::ScenePtr _scene; + TextureRenderer* _renderer; + QString _leftNodeId, _leftOutput, _rightNodeId, _rightInput; + bool _firstRedo = true; +}; diff --git a/src/texturelab/undo/addframecommand.cpp b/src/texturelab/undo/addframecommand.cpp new file mode 100644 index 00000000..9be7972b --- /dev/null +++ b/src/texturelab/undo/addframecommand.cpp @@ -0,0 +1,36 @@ +#include "addframecommand.h" + +#include "graph/frame.h" +#include "graph/scene.h" + +AddFrameCommand::AddFrameCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QString& frameId, + QVector2D pos) + : QUndoCommand("Add Frame") + , _project(project) + , _scene(scene) + , _frameId(frameId) + , _pos(pos) +{} + +void AddFrameCommand::redo() +{ + auto modelFrame = FramePtr(new Frame()); + modelFrame->id = _frameId; + modelFrame->pos = _pos; + _project->frames[_frameId] = modelFrame; + + auto gframe = nodegraph::Frame::create(); + gframe->setId(_frameId); + gframe->setPos(_pos.x(), _pos.y()); + _scene->addFrame(gframe); +} + +void AddFrameCommand::undo() +{ + auto gframe = _scene->getFrameById(_frameId); + if (gframe) + _scene->removeFrame(gframe); + _project->frames.remove(_frameId); +} diff --git a/src/texturelab/undo/addframecommand.h b/src/texturelab/undo/addframecommand.h new file mode 100644 index 00000000..46b0b17f --- /dev/null +++ b/src/texturelab/undo/addframecommand.h @@ -0,0 +1,27 @@ +#pragma once + +#include "../models.h" +#include +#include + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +class AddFrameCommand : public QUndoCommand { +public: + AddFrameCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QString& frameId, + QVector2D pos); + + void redo() override; + void undo() override; + +private: + TextureProjectPtr _project; + nodegraph::ScenePtr _scene; + QString _frameId; + QVector2D _pos; +}; diff --git a/src/texturelab/undo/addnodecommand.cpp b/src/texturelab/undo/addnodecommand.cpp new file mode 100644 index 00000000..2f69f8f2 --- /dev/null +++ b/src/texturelab/undo/addnodecommand.cpp @@ -0,0 +1,69 @@ +#include "addnodecommand.h" + +#include "../graphics/texturerenderer.h" +#include "../libraries/library.h" +#include "graph/scene.h" + +#include + +static QString newId() +{ + return QUuid::createUuid().toString(QUuid::WithoutBraces); +} + +static void addNodeToScene(nodegraph::ScenePtr scene, const TextureNodePtr& node) +{ + auto gnode = nodegraph::Node::create(); + gnode->setId(node->id); + gnode->setName(node->title); + for (auto& input : node->inputs) + gnode->addInPort(input); + gnode->addOutPort("output"); + gnode->setCenter(node->pos.x(), node->pos.y()); + scene->addNode(gnode); +} + +AddNodeCommand::AddNodeCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& typeName, + QVector2D pos) + : QUndoCommand(QString("Add %1 Node").arg(typeName)) + , _project(project) + , _scene(scene) + , _renderer(renderer) + , _typeName(typeName) + , _pos(pos) + , _nodeId(newId()) +{} + +void AddNodeCommand::redo() +{ + auto node = _project->library->createNode(_typeName); + node->id = _nodeId; + node->pos = _pos; + _project->addNode(node); + addNodeToScene(_scene, node); + if (_renderer) + _renderer->update(); +} + +void AddNodeCommand::undo() +{ + auto sceneNode = _scene->getNodeById(_nodeId); + if (sceneNode) + _scene->removeNode(sceneNode); + + for (auto key : _project->connections.keys()) { + auto con = _project->connections.value(key); + if (con->leftNode->id == _nodeId || con->rightNode->id == _nodeId) { + if (con->leftNode->id == _nodeId) + con->rightNode->isDirty = true; + _project->connections.remove(key); + } + } + + _project->nodes.remove(_nodeId); + if (_renderer) + _renderer->update(); +} diff --git a/src/texturelab/undo/addnodecommand.h b/src/texturelab/undo/addnodecommand.h new file mode 100644 index 00000000..2240b683 --- /dev/null +++ b/src/texturelab/undo/addnodecommand.h @@ -0,0 +1,32 @@ +#pragma once + +#include "../models.h" +#include +#include + +class TextureRenderer; + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +class AddNodeCommand : public QUndoCommand { +public: + AddNodeCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& typeName, + QVector2D pos); + + void redo() override; + void undo() override; + +private: + TextureProjectPtr _project; + nodegraph::ScenePtr _scene; + TextureRenderer* _renderer; + QString _typeName; + QVector2D _pos; + QString _nodeId; // generated in constructor, reused on re-redo +}; diff --git a/src/texturelab/undo/deleteitemscommand.cpp b/src/texturelab/undo/deleteitemscommand.cpp new file mode 100644 index 00000000..8b3109b6 --- /dev/null +++ b/src/texturelab/undo/deleteitemscommand.cpp @@ -0,0 +1,202 @@ +#include "deleteitemscommand.h" + +#include "../graphics/texturerenderer.h" +#include "../libraries/library.h" +#include "../props.h" +#include "graph/comment.h" +#include "graph/frame.h" +#include "graph/scene.h" + +static void addNodeToScene(nodegraph::ScenePtr scene, const TextureNodePtr& node) +{ + auto gnode = nodegraph::Node::create(); + gnode->setId(node->id); + gnode->setName(node->title); + for (auto& input : node->inputs) + gnode->addInPort(input); + gnode->addOutPort("output"); + gnode->setCenter(node->pos.x(), node->pos.y()); + scene->addNode(gnode); +} + +DeleteItemsCommand::DeleteItemsCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QList& nodeIds, + const QList& frameIds, + const QList& commentIds) + : QUndoCommand() + , _project(project) + , _scene(scene) + , _renderer(renderer) +{ + int total = nodeIds.size() + frameIds.size() + commentIds.size(); + setText(QString("Delete %1 Item%2").arg(total).arg(total == 1 ? "" : "s")); + + QSet deletedNodeIds(nodeIds.begin(), nodeIds.end()); + + for (const auto& id : nodeIds) { + auto node = _project->getNodeById(id); + if (!node) + continue; + SerializedNode sn; + sn.typeName = node->typeName; + sn.id = node->id; + sn.exportName = node->exportName; + sn.randomSeed = node->randomSeed; + sn.pos = node->pos; + auto gnode = _scene->getNodeById(id); + if (gnode) + sn.pos = QVector2D(gnode->getCenter()); + for (auto key : node->props.keys()) + sn.props[key] = node->props[key]->toJsonValue(); + _nodes.append(sn); + } + + for (const auto& con : _project->connections) { + if (deletedNodeIds.contains(con->leftNode->id) || + deletedNodeIds.contains(con->rightNode->id)) { + SerializedConnection sc; + sc.id = con->id; + sc.leftNodeId = con->leftNode->id; + sc.leftOutput = con->leftNodeOutputName.isEmpty() ? "output" : con->leftNodeOutputName; + sc.rightNodeId = con->rightNode->id; + sc.rightInput = con->rightNodeInputName; + _connections.append(sc); + } + } + + for (const auto& id : frameIds) { + auto frame = _project->frames.value(id); + if (!frame) + continue; + SerializedFrame sf; + sf.id = frame->id; + sf.title = frame->text; + sf.color = frame->color; + sf.pos = frame->pos; + sf.size = frame->size; + auto gframe = _scene->getFrameById(id); + if (gframe) { + sf.pos = QVector2D(gframe->pos()); + auto sz = gframe->frameRect().size(); + sf.size = QVector2D(sz.width(), sz.height()); + } + _frames.append(sf); + } + + for (const auto& id : commentIds) { + auto comment = _project->comments.value(id); + if (!comment) + continue; + SerializedComment sc; + sc.id = comment->id; + sc.text = comment->text; + sc.pos = comment->pos; + auto gcomment = _scene->getCommentById(id); + if (gcomment) + sc.pos = QVector2D(gcomment->pos()); + _comments.append(sc); + } +} + +void DeleteItemsCommand::redo() +{ + for (const auto& sc : _connections) { + auto con = _project->removeConnection(sc.leftNodeId, sc.rightNodeId, sc.rightInput); + if (con && con->rightNode) + con->rightNode->isDirty = true; + } + + for (const auto& sn : _nodes) { + auto gnode = _scene->getNodeById(sn.id); + if (gnode) + _scene->removeNode(gnode); + _project->nodes.remove(sn.id); + } + + for (const auto& sf : _frames) { + auto gframe = _scene->getFrameById(sf.id); + if (gframe) + _scene->removeFrame(gframe); + _project->frames.remove(sf.id); + } + + for (const auto& sc : _comments) { + auto gcomment = _scene->getCommentById(sc.id); + if (gcomment) + _scene->removeComment(gcomment); + _project->comments.remove(sc.id); + } + + if (_renderer) + _renderer->update(); +} + +void DeleteItemsCommand::undo() +{ + for (const auto& sn : _nodes) { + auto node = _project->library->createNode(sn.typeName); + node->id = sn.id; + node->exportName = sn.exportName; + node->randomSeed = sn.randomSeed; + node->pos = sn.pos; + node->isDirty = true; + for (auto key : sn.props.keys()) { + auto prop = node->getProp(key); + if (prop) + prop->fromJsonValue(sn.props[key]); + } + _project->addNode(node); + addNodeToScene(_scene, node); + } + + for (const auto& sc : _connections) { + auto leftNode = _project->getNodeById(sc.leftNodeId); + auto rightNode = _project->getNodeById(sc.rightNodeId); + if (!leftNode || !rightNode) + continue; + _project->addConnection(leftNode, rightNode, sc.rightInput); + rightNode->isDirty = true; + auto leftG = _scene->getNodeById(sc.leftNodeId); + auto rightG = _scene->getNodeById(sc.rightNodeId); + if (leftG && rightG) + _scene->connectNodes(leftG, sc.leftOutput, rightG, sc.rightInput); + } + + for (const auto& sf : _frames) { + auto modelFrame = FramePtr(new Frame()); + modelFrame->id = sf.id; + modelFrame->text = sf.title; + modelFrame->color = sf.color; + modelFrame->pos = sf.pos; + modelFrame->size = sf.size; + _project->frames[sf.id] = modelFrame; + + auto gframe = nodegraph::Frame::create(); + gframe->setId(sf.id); + gframe->setTitle(sf.title); + gframe->setColor(sf.color); + gframe->setPos(sf.pos.x(), sf.pos.y()); + if (sf.size.x() > 0 && sf.size.y() > 0) + gframe->setSize(sf.size.x(), sf.size.y()); + _scene->addFrame(gframe); + } + + for (const auto& sc : _comments) { + auto modelComment = CommentPtr(new Comment()); + modelComment->id = sc.id; + modelComment->text = sc.text; + modelComment->pos = sc.pos; + _project->comments[sc.id] = modelComment; + + auto gcomment = nodegraph::Comment::create(); + gcomment->setId(sc.id); + gcomment->setText(sc.text); + gcomment->setPos(sc.pos.x(), sc.pos.y()); + _scene->addComment(gcomment); + } + + if (_renderer) + _renderer->update(); +} diff --git a/src/texturelab/undo/deleteitemscommand.h b/src/texturelab/undo/deleteitemscommand.h new file mode 100644 index 00000000..f44f82c5 --- /dev/null +++ b/src/texturelab/undo/deleteitemscommand.h @@ -0,0 +1,59 @@ +#pragma once + +#include "../models.h" +#include +#include +#include +#include + +class TextureRenderer; + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +class DeleteItemsCommand : public QUndoCommand { +public: + struct SerializedNode { + QString typeName, id, exportName; + long randomSeed; + QVector2D pos; + QJsonObject props; + }; + + struct SerializedConnection { + QString id, leftNodeId, leftOutput, rightNodeId, rightInput; + }; + + struct SerializedFrame { + QString id, title; + QColor color; + QVector2D pos, size; + }; + + struct SerializedComment { + QString id, text; + QVector2D pos; + }; + + DeleteItemsCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QList& nodeIds, + const QList& frameIds, + const QList& commentIds); + + void redo() override; + void undo() override; + +private: + TextureProjectPtr _project; + nodegraph::ScenePtr _scene; + TextureRenderer* _renderer; + + QList _nodes; + QList _connections; + QList _frames; + QList _comments; +}; diff --git a/src/texturelab/undo/editcommentcommand.cpp b/src/texturelab/undo/editcommentcommand.cpp new file mode 100644 index 00000000..b1c426d3 --- /dev/null +++ b/src/texturelab/undo/editcommentcommand.cpp @@ -0,0 +1,44 @@ +#include "editcommentcommand.h" + +#include "graph/comment.h" +#include "graph/scene.h" + +EditCommentCommand::EditCommentCommand(CommentPtr comment, + nodegraph::ScenePtr scene, + const QString& oldText, + const QString& newText) + : QUndoCommand("Edit Comment") + , _comment(comment) + , _scene(scene) + , _commentId(comment->id) + , _oldText(oldText) + , _newText(newText) +{} + +bool EditCommentCommand::mergeWith(const QUndoCommand* other) +{ + auto* cmd = static_cast(other); + if (cmd->_commentId != _commentId) + return false; + _newText = cmd->_newText; + return true; +} + +void EditCommentCommand::apply(const QString& text) +{ + _comment->text = text; + auto gcomment = _scene->getCommentById(_commentId); + if (gcomment) + gcomment->setText(text); +} + +void EditCommentCommand::redo() +{ + if (_firstRedo) { _firstRedo = false; return; } + apply(_newText); +} + +void EditCommentCommand::undo() +{ + apply(_oldText); +} diff --git a/src/texturelab/undo/editcommentcommand.h b/src/texturelab/undo/editcommentcommand.h new file mode 100644 index 00000000..1f499aaf --- /dev/null +++ b/src/texturelab/undo/editcommentcommand.h @@ -0,0 +1,33 @@ +#pragma once + +#include "../models.h" +#include "undocommandids.h" +#include + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +class EditCommentCommand : public QUndoCommand { +public: + EditCommentCommand(CommentPtr comment, + nodegraph::ScenePtr scene, + const QString& oldText, + const QString& newText); + + int id() const override { return UndoCommandId::EditComment; } + bool mergeWith(const QUndoCommand* other) override; + + void redo() override; + void undo() override; + +private: + void apply(const QString& text); + + CommentPtr _comment; + nodegraph::ScenePtr _scene; + QString _commentId; + QString _oldText, _newText; + bool _firstRedo = true; +}; diff --git a/src/texturelab/undo/editframecommand.cpp b/src/texturelab/undo/editframecommand.cpp new file mode 100644 index 00000000..fc9a65fe --- /dev/null +++ b/src/texturelab/undo/editframecommand.cpp @@ -0,0 +1,48 @@ +#include "editframecommand.h" + +#include "graph/frame.h" +#include "graph/scene.h" + +EditFrameCommand::EditFrameCommand(FramePtr frame, + nodegraph::ScenePtr scene, + const QString& oldTitle, const QColor& oldColor, + const QString& newTitle, const QColor& newColor) + : QUndoCommand("Edit Frame") + , _frame(frame) + , _scene(scene) + , _frameId(frame->id) + , _oldTitle(oldTitle), _newTitle(newTitle) + , _oldColor(oldColor), _newColor(newColor) +{} + +bool EditFrameCommand::mergeWith(const QUndoCommand* other) +{ + auto* cmd = static_cast(other); + if (cmd->_frameId != _frameId) + return false; + _newTitle = cmd->_newTitle; + _newColor = cmd->_newColor; + return true; +} + +void EditFrameCommand::apply(const QString& title, const QColor& color) +{ + _frame->text = title; + _frame->color = color; + auto gframe = _scene->getFrameById(_frameId); + if (gframe) { + gframe->setTitle(title); + gframe->setColor(color); + } +} + +void EditFrameCommand::redo() +{ + if (_firstRedo) { _firstRedo = false; return; } + apply(_newTitle, _newColor); +} + +void EditFrameCommand::undo() +{ + apply(_oldTitle, _oldColor); +} diff --git a/src/texturelab/undo/editframecommand.h b/src/texturelab/undo/editframecommand.h new file mode 100644 index 00000000..49d7e3d4 --- /dev/null +++ b/src/texturelab/undo/editframecommand.h @@ -0,0 +1,35 @@ +#pragma once + +#include "../models.h" +#include "undocommandids.h" +#include +#include + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +class EditFrameCommand : public QUndoCommand { +public: + EditFrameCommand(FramePtr frame, + nodegraph::ScenePtr scene, + const QString& oldTitle, const QColor& oldColor, + const QString& newTitle, const QColor& newColor); + + int id() const override { return UndoCommandId::EditFrame; } + bool mergeWith(const QUndoCommand* other) override; + + void redo() override; + void undo() override; + +private: + void apply(const QString& title, const QColor& color); + + FramePtr _frame; + nodegraph::ScenePtr _scene; + QString _frameId; + QString _oldTitle, _newTitle; + QColor _oldColor, _newColor; + bool _firstRedo = true; +}; diff --git a/src/texturelab/undo/moveitemscommand.cpp b/src/texturelab/undo/moveitemscommand.cpp new file mode 100644 index 00000000..5fd2ff2f --- /dev/null +++ b/src/texturelab/undo/moveitemscommand.cpp @@ -0,0 +1,45 @@ +#include "moveitemscommand.h" + +#include "graph/scene.h" + +MoveItemsCommand::MoveItemsCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QMap& oldPositions, + const QMap& newPositions) + : QUndoCommand("Move Nodes") + , _project(project) + , _scene(scene) + , _oldPositions(oldPositions) + , _newPositions(newPositions) +{} + +bool MoveItemsCommand::mergeWith(const QUndoCommand* other) +{ + auto* cmd = static_cast(other); + if (cmd->_newPositions.keys() != _newPositions.keys()) + return false; + _newPositions = cmd->_newPositions; + return true; +} + +void MoveItemsCommand::applyPositions(const QMap& positions) +{ + for (auto it = positions.begin(); it != positions.end(); ++it) { + auto gnode = _scene->getNodeById(it.key()); + if (gnode) + gnode->setCenter(it.value().x(), it.value().y()); + auto node = _project->getNodeById(it.key()); + if (node) + node->pos = QVector2D(it.value()); + } +} + +void MoveItemsCommand::redo() +{ + applyPositions(_newPositions); +} + +void MoveItemsCommand::undo() +{ + applyPositions(_oldPositions); +} diff --git a/src/texturelab/undo/moveitemscommand.h b/src/texturelab/undo/moveitemscommand.h new file mode 100644 index 00000000..34943efa --- /dev/null +++ b/src/texturelab/undo/moveitemscommand.h @@ -0,0 +1,34 @@ +#pragma once + +#include "../models.h" +#include "undocommandids.h" +#include +#include +#include + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +class MoveItemsCommand : public QUndoCommand { +public: + MoveItemsCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QMap& oldPositions, + const QMap& newPositions); + + int id() const override { return UndoCommandId::MoveItems; } + bool mergeWith(const QUndoCommand* other) override; + + void redo() override; + void undo() override; + +private: + void applyPositions(const QMap& positions); + + TextureProjectPtr _project; + nodegraph::ScenePtr _scene; + QMap _oldPositions; + QMap _newPositions; +}; diff --git a/src/texturelab/undo/pastecommand.cpp b/src/texturelab/undo/pastecommand.cpp new file mode 100644 index 00000000..1a965ae4 --- /dev/null +++ b/src/texturelab/undo/pastecommand.cpp @@ -0,0 +1,119 @@ +#include "pastecommand.h" + +#include "../clipboard.h" +#include "../graphics/texturerenderer.h" +#include "graph/comment.h" +#include "graph/frame.h" +#include "graph/scene.h" + +PasteCommand::PasteCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + QPointF viewCenter) + : QUndoCommand() + , _project(project) + , _scene(scene) + , _renderer(renderer) +{ + if (!Clipboard::pasteItems(project, viewCenter, + _nodes, _connections, _comments, _frames)) + return; + + int total = _nodes.size() + _frames.size() + _comments.size(); + setText(QString("Paste %1 Item%2").arg(total).arg(total == 1 ? "" : "s")); +} + +bool PasteCommand::isEmpty() const +{ + return _nodes.isEmpty() && _frames.isEmpty() && _comments.isEmpty(); +} + +void PasteCommand::addNodeToScene(const TextureNodePtr& node) +{ + auto gnode = nodegraph::Node::create(); + gnode->setId(node->id); + gnode->setName(node->title); + for (auto& input : node->inputs) + gnode->addInPort(input); + gnode->addOutPort("output"); + gnode->setCenter(node->pos.x(), node->pos.y()); + _scene->addNode(gnode); +} + +void PasteCommand::redo() +{ + _scene->clearSelection(); + + for (auto& node : _nodes) { + _project->nodes[node->id] = node; + addNodeToScene(node); + auto gnode = _scene->getNodeById(node->id); + if (gnode) + gnode->setSelected(true); + } + + for (auto& con : _connections) { + _project->connections[con->id] = con; + con->rightNode->isDirty = true; + auto leftG = _scene->getNodeById(con->leftNode->id); + auto rightG = _scene->getNodeById(con->rightNode->id); + if (leftG && rightG) + _scene->connectNodes(leftG, "output", rightG, con->rightNodeInputName); + } + + for (auto& comment : _comments) { + _project->comments[comment->id] = comment; + auto gc = nodegraph::Comment::create(); + gc->setId(comment->id); + gc->setText(comment->text); + gc->setPos(comment->pos.x(), comment->pos.y()); + _scene->addComment(gc); + gc->setSelected(true); + } + + for (auto& frame : _frames) { + _project->frames[frame->id] = frame; + auto gf = nodegraph::Frame::create(); + gf->setId(frame->id); + gf->setTitle(frame->text); + gf->setColor(frame->color); + gf->setPos(frame->pos.x(), frame->pos.y()); + if (frame->size.x() > 0 && frame->size.y() > 0) + gf->setSize(frame->size.x(), frame->size.y()); + _scene->addFrame(gf); + gf->setSelected(true); + } + + if (_renderer) + _renderer->update(); +} + +void PasteCommand::undo() +{ + for (auto& con : _connections) + _project->connections.remove(con->id); + + for (auto& node : _nodes) { + auto gnode = _scene->getNodeById(node->id); + if (gnode) + _scene->removeNode(gnode); + _project->nodes.remove(node->id); + } + + for (auto& comment : _comments) { + auto gc = _scene->getCommentById(comment->id); + if (gc) + _scene->removeComment(gc); + _project->comments.remove(comment->id); + } + + for (auto& frame : _frames) { + auto gf = _scene->getFrameById(frame->id); + if (gf) + _scene->removeFrame(gf); + _project->frames.remove(frame->id); + } + + if (_renderer) + _renderer->update(); +} diff --git a/src/texturelab/undo/pastecommand.h b/src/texturelab/undo/pastecommand.h new file mode 100644 index 00000000..a466e523 --- /dev/null +++ b/src/texturelab/undo/pastecommand.h @@ -0,0 +1,37 @@ +#pragma once + +#include "../models.h" +#include +#include + +class TextureRenderer; + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +class PasteCommand : public QUndoCommand { +public: + PasteCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + QPointF viewCenter); + + void redo() override; + void undo() override; + + bool isEmpty() const; + +private: + void addNodeToScene(const TextureNodePtr& node); + + TextureProjectPtr _project; + nodegraph::ScenePtr _scene; + TextureRenderer* _renderer; + + QList _nodes; + QList _connections; + QList _comments; + QList _frames; +}; diff --git a/src/texturelab/undo/propertychangecommand.cpp b/src/texturelab/undo/propertychangecommand.cpp new file mode 100644 index 00000000..cb7be4e3 --- /dev/null +++ b/src/texturelab/undo/propertychangecommand.cpp @@ -0,0 +1,49 @@ +#include "propertychangecommand.h" + +#include "../graphics/texturerenderer.h" + +PropertyChangeCommand::PropertyChangeCommand(TextureNodePtr node, + TextureProjectPtr project, + TextureRenderer* renderer, + const QString& propName, + QVariant oldValue, + QVariant newValue) + : QUndoCommand(QString("Change %1").arg(propName)) + , _node(node) + , _project(project) + , _renderer(renderer) + , _propName(propName) + , _oldValue(oldValue) + , _newValue(newValue) +{} + +bool PropertyChangeCommand::mergeWith(const QUndoCommand* other) +{ + auto* cmd = static_cast(other); + if (cmd->_node != _node || cmd->_propName != _propName) + return false; + _newValue = cmd->_newValue; + return true; +} + +void PropertyChangeCommand::applyValue(const QVariant& value) +{ + _node->setProp(_propName, value); + _project->markNodeAsDirty(_node); + if (_renderer) + _renderer->update(); +} + +void PropertyChangeCommand::redo() +{ + if (_firstRedo) { + _firstRedo = false; + return; // already applied by the signal handler + } + applyValue(_newValue); +} + +void PropertyChangeCommand::undo() +{ + applyValue(_oldValue); +} diff --git a/src/texturelab/undo/propertychangecommand.h b/src/texturelab/undo/propertychangecommand.h new file mode 100644 index 00000000..efc9f6fb --- /dev/null +++ b/src/texturelab/undo/propertychangecommand.h @@ -0,0 +1,38 @@ +#pragma once + +#include "../models.h" +#include "undocommandids.h" +#include +#include + +class TextureRenderer; + +// A single node property value change. Consecutive changes to the same prop +// merge into one undo step. First redo is skipped (already applied by the +// signal handler). +class PropertyChangeCommand : public QUndoCommand { +public: + PropertyChangeCommand(TextureNodePtr node, + TextureProjectPtr project, + TextureRenderer* renderer, + const QString& propName, + QVariant oldValue, + QVariant newValue); + + int id() const override { return UndoCommandId::PropertyChange; } + bool mergeWith(const QUndoCommand* other) override; + + void redo() override; + void undo() override; + +private: + void applyValue(const QVariant& value); + + TextureNodePtr _node; + TextureProjectPtr _project; + TextureRenderer* _renderer; + QString _propName; + QVariant _oldValue; + QVariant _newValue; + bool _firstRedo = true; +}; diff --git a/src/texturelab/undo/randomseedchangecommand.cpp b/src/texturelab/undo/randomseedchangecommand.cpp new file mode 100644 index 00000000..a9cb8601 --- /dev/null +++ b/src/texturelab/undo/randomseedchangecommand.cpp @@ -0,0 +1,36 @@ +#include "randomseedchangecommand.h" + +#include "../graphics/texturerenderer.h" + +RandomSeedChangeCommand::RandomSeedChangeCommand(TextureNodePtr node, + TextureProjectPtr project, + TextureRenderer* renderer, + long oldSeed, + long newSeed) + : QUndoCommand("Change Random Seed") + , _node(node) + , _project(project) + , _renderer(renderer) + , _oldSeed(oldSeed) + , _newSeed(newSeed) +{} + +void RandomSeedChangeCommand::redo() +{ + if (_firstRedo) { + _firstRedo = false; + return; + } + _node->randomSeed = _newSeed; + _project->markNodeAsDirty(_node); + if (_renderer) + _renderer->update(); +} + +void RandomSeedChangeCommand::undo() +{ + _node->randomSeed = _oldSeed; + _project->markNodeAsDirty(_node); + if (_renderer) + _renderer->update(); +} diff --git a/src/texturelab/undo/randomseedchangecommand.h b/src/texturelab/undo/randomseedchangecommand.h new file mode 100644 index 00000000..3eafd46e --- /dev/null +++ b/src/texturelab/undo/randomseedchangecommand.h @@ -0,0 +1,25 @@ +#pragma once + +#include "../models.h" +#include + +class TextureRenderer; + +class RandomSeedChangeCommand : public QUndoCommand { +public: + RandomSeedChangeCommand(TextureNodePtr node, + TextureProjectPtr project, + TextureRenderer* renderer, + long oldSeed, + long newSeed); + + void redo() override; + void undo() override; + +private: + TextureNodePtr _node; + TextureProjectPtr _project; + TextureRenderer* _renderer; + long _oldSeed, _newSeed; + bool _firstRedo = true; +}; diff --git a/src/texturelab/undo/removeconnectioncommand.cpp b/src/texturelab/undo/removeconnectioncommand.cpp new file mode 100644 index 00000000..bdbeccf6 --- /dev/null +++ b/src/texturelab/undo/removeconnectioncommand.cpp @@ -0,0 +1,61 @@ +#include "removeconnectioncommand.h" + +#include "../graphics/texturerenderer.h" +#include "graph/scene.h" + +RemoveConnectionCommand::RemoveConnectionCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& leftNodeId, + const QString& leftOutput, + const QString& rightNodeId, + const QString& rightInput) + : QUndoCommand("Remove Connection") + , _project(project) + , _scene(scene) + , _renderer(renderer) + , _leftNodeId(leftNodeId) + , _leftOutput(leftOutput) + , _rightNodeId(rightNodeId) + , _rightInput(rightInput) +{} + +void RemoveConnectionCommand::redo() +{ + if (_firstRedo) { + // Scene already removed it — just remove from project model + auto con = _project->removeConnection(_leftNodeId, _rightNodeId, _rightInput); + if (con && con->rightNode) + con->rightNode->isDirty = true; + _firstRedo = false; + } else { + auto rightG = _scene->getNodeById(_rightNodeId); + if (rightG) { + auto port = rightG->getInPortByName(_rightInput); + if (port && !port->connections.isEmpty()) + _scene->removeConnection(port->connections.first()); + } + auto con = _project->removeConnection(_leftNodeId, _rightNodeId, _rightInput); + if (con && con->rightNode) + con->rightNode->isDirty = true; + } + if (_renderer) + _renderer->update(); +} + +void RemoveConnectionCommand::undo() +{ + auto leftG = _scene->getNodeById(_leftNodeId); + auto rightG = _scene->getNodeById(_rightNodeId); + if (leftG && rightG) + _scene->connectNodes(leftG, _leftOutput, rightG, _rightInput); + + auto left = _project->getNodeById(_leftNodeId); + auto right = _project->getNodeById(_rightNodeId); + if (left && right) { + _project->addConnection(left, right, _rightInput); + right->isDirty = true; + } + if (_renderer) + _renderer->update(); +} diff --git a/src/texturelab/undo/removeconnectioncommand.h b/src/texturelab/undo/removeconnectioncommand.h new file mode 100644 index 00000000..9fc1eb7a --- /dev/null +++ b/src/texturelab/undo/removeconnectioncommand.h @@ -0,0 +1,34 @@ +#pragma once + +#include "../models.h" +#include + +class TextureRenderer; + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +// Records a connection removed in the scene. First redo only removes from the +// project model (scene already done). +class RemoveConnectionCommand : public QUndoCommand { +public: + RemoveConnectionCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& leftNodeId, + const QString& leftOutput, + const QString& rightNodeId, + const QString& rightInput); + + void redo() override; + void undo() override; + +private: + TextureProjectPtr _project; + nodegraph::ScenePtr _scene; + TextureRenderer* _renderer; + QString _leftNodeId, _leftOutput, _rightNodeId, _rightInput; + bool _firstRedo = true; +}; diff --git a/src/texturelab/undo/texturechannelassigncommand.cpp b/src/texturelab/undo/texturechannelassigncommand.cpp new file mode 100644 index 00000000..8fec1a4d --- /dev/null +++ b/src/texturelab/undo/texturechannelassigncommand.cpp @@ -0,0 +1,36 @@ +#include "texturechannelassigncommand.h" + +TextureChannelAssignCommand::TextureChannelAssignCommand( + TextureProjectPtr project, + TextureChannel channel, + const QString& oldNodeId, + const QString& newNodeId, + std::function syncViewer) + : QUndoCommand("Assign Texture Channel") + , _project(project) + , _channel(channel) + , _oldNodeId(oldNodeId) + , _newNodeId(newNodeId) + , _syncViewer(syncViewer) +{} + +void TextureChannelAssignCommand::apply(const QString& nodeId) +{ + if (nodeId.isEmpty()) + _project->textureChannels.remove(_channel); + else + _project->textureChannels[_channel] = nodeId; + if (_syncViewer) + _syncViewer(); +} + +void TextureChannelAssignCommand::redo() +{ + if (_firstRedo) { _firstRedo = false; return; } + apply(_newNodeId); +} + +void TextureChannelAssignCommand::undo() +{ + apply(_oldNodeId); +} diff --git a/src/texturelab/undo/texturechannelassigncommand.h b/src/texturelab/undo/texturechannelassigncommand.h new file mode 100644 index 00000000..c91776a0 --- /dev/null +++ b/src/texturelab/undo/texturechannelassigncommand.h @@ -0,0 +1,26 @@ +#pragma once + +#include "../models.h" +#include +#include + +class TextureChannelAssignCommand : public QUndoCommand { +public: + TextureChannelAssignCommand(TextureProjectPtr project, + TextureChannel channel, + const QString& oldNodeId, + const QString& newNodeId, + std::function syncViewer); + + void redo() override; + void undo() override; + +private: + void apply(const QString& nodeId); + + TextureProjectPtr _project; + TextureChannel _channel; + QString _oldNodeId, _newNodeId; + std::function _syncViewer; + bool _firstRedo = true; +}; diff --git a/src/texturelab/undo/undocommandids.h b/src/texturelab/undo/undocommandids.h new file mode 100644 index 00000000..0d80bce0 --- /dev/null +++ b/src/texturelab/undo/undocommandids.h @@ -0,0 +1,8 @@ +#pragma once + +namespace UndoCommandId { +static constexpr int PropertyChange = 1; +static constexpr int MoveItems = 2; +static constexpr int EditFrame = 3; +static constexpr int EditComment = 4; +} // namespace UndoCommandId diff --git a/src/texturelab/undo/undocommands.cpp b/src/texturelab/undo/undocommands.cpp new file mode 100644 index 00000000..07d9a5fb --- /dev/null +++ b/src/texturelab/undo/undocommands.cpp @@ -0,0 +1,871 @@ +#include "undocommands.h" + +#include "../clipboard.h" +#include "../graphics/texturerenderer.h" +#include "../libraries/library.h" +#include "../props.h" +#include "graph/comment.h" +#include "graph/frame.h" +#include "graph/scene.h" + +#include + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static QString newId() +{ + return QUuid::createUuid().toString(QUuid::WithoutBraces); +} + +// Add a TextureNode to the nodegraph scene (mirrors GraphWidget::addNode) +static void addNodeToScene(nodegraph::ScenePtr scene, const TextureNodePtr& node) +{ + auto gnode = nodegraph::Node::create(); + gnode->setId(node->id); + gnode->setName(node->title); + for (auto& input : node->inputs) + gnode->addInPort(input); + gnode->addOutPort("output"); + gnode->setCenter(node->pos.x(), node->pos.y()); + scene->addNode(gnode); +} + +// --------------------------------------------------------------------------- +// AddNodeCommand +// --------------------------------------------------------------------------- + +AddNodeCommand::AddNodeCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& typeName, + QVector2D pos) + : QUndoCommand(QString("Add %1 Node").arg(typeName)) + , _project(project) + , _scene(scene) + , _renderer(renderer) + , _typeName(typeName) + , _pos(pos) + , _nodeId(newId()) +{} + +void AddNodeCommand::redo() +{ + auto node = _project->library->createNode(_typeName); + node->id = _nodeId; + node->pos = _pos; + _project->addNode(node); + addNodeToScene(_scene, node); + if (_renderer) + _renderer->update(); +} + +void AddNodeCommand::undo() +{ + auto sceneNode = _scene->getNodeById(_nodeId); + if (sceneNode) + _scene->removeNode(sceneNode); + + // Remove all connections involving this node from the project + for (auto key : _project->connections.keys()) { + auto con = _project->connections.value(key); + if (con->leftNode->id == _nodeId || con->rightNode->id == _nodeId) { + if (con->leftNode->id == _nodeId) + con->rightNode->isDirty = true; + _project->connections.remove(key); + } + } + + _project->nodes.remove(_nodeId); + if (_renderer) + _renderer->update(); +} + +// --------------------------------------------------------------------------- +// DeleteItemsCommand +// --------------------------------------------------------------------------- + +DeleteItemsCommand::DeleteItemsCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QList& nodeIds, + const QList& frameIds, + const QList& commentIds) + : QUndoCommand() + , _project(project) + , _scene(scene) + , _renderer(renderer) +{ + // Determine display text + int total = nodeIds.size() + frameIds.size() + commentIds.size(); + setText(QString("Delete %1 Item%2").arg(total).arg(total == 1 ? "" : "s")); + + QSet deletedNodeIds(nodeIds.begin(), nodeIds.end()); + + // Serialize nodes + for (const auto& id : nodeIds) { + auto node = _project->getNodeById(id); + if (!node) + continue; + SerializedNode sn; + sn.typeName = node->typeName; + sn.id = node->id; + sn.exportName = node->exportName; + sn.randomSeed = node->randomSeed; + sn.pos = node->pos; + // Sync visual position before serializing + auto gnode = _scene->getNodeById(id); + if (gnode) + sn.pos = QVector2D(gnode->getCenter()); + for (auto key : node->props.keys()) + sn.props[key] = node->props[key]->toJsonValue(); + _nodes.append(sn); + } + + // Serialize all connections touching any deleted node + for (const auto& con : _project->connections) { + if (deletedNodeIds.contains(con->leftNode->id) || + deletedNodeIds.contains(con->rightNode->id)) { + SerializedConnection sc; + sc.id = con->id; + sc.leftNodeId = con->leftNode->id; + sc.leftOutput = con->leftNodeOutputName.isEmpty() ? "output" : con->leftNodeOutputName; + sc.rightNodeId = con->rightNode->id; + sc.rightInput = con->rightNodeInputName; + _connections.append(sc); + } + } + + // Serialize frames + for (const auto& id : frameIds) { + auto frame = _project->frames.value(id); + if (!frame) + continue; + SerializedFrame sf; + sf.id = frame->id; + sf.title = frame->text; + sf.color = frame->color; + sf.pos = frame->pos; + sf.size = frame->size; + // Sync visual position/size + auto gframe = _scene->getFrameById(id); + if (gframe) { + sf.pos = QVector2D(gframe->pos()); + auto sz = gframe->frameRect().size(); + sf.size = QVector2D(sz.width(), sz.height()); + } + _frames.append(sf); + } + + // Serialize comments + for (const auto& id : commentIds) { + auto comment = _project->comments.value(id); + if (!comment) + continue; + SerializedComment sc; + sc.id = comment->id; + sc.text = comment->text; + sc.pos = comment->pos; + auto gcomment = _scene->getCommentById(id); + if (gcomment) + sc.pos = QVector2D(gcomment->pos()); + _comments.append(sc); + } +} + +void DeleteItemsCommand::redo() +{ + // Remove project connections first (mark downstream nodes dirty) + for (const auto& sc : _connections) { + auto con = _project->removeConnection(sc.leftNodeId, sc.rightNodeId, sc.rightInput); + if (con && con->rightNode) + con->rightNode->isDirty = true; + } + + // Remove nodes (scene::removeNode also removes scene-level connections) + for (const auto& sn : _nodes) { + auto gnode = _scene->getNodeById(sn.id); + if (gnode) + _scene->removeNode(gnode); + _project->nodes.remove(sn.id); + } + + for (const auto& sf : _frames) { + auto gframe = _scene->getFrameById(sf.id); + if (gframe) + _scene->removeFrame(gframe); + _project->frames.remove(sf.id); + } + + for (const auto& sc : _comments) { + auto gcomment = _scene->getCommentById(sc.id); + if (gcomment) + _scene->removeComment(gcomment); + _project->comments.remove(sc.id); + } + + if (_renderer) + _renderer->update(); +} + +void DeleteItemsCommand::undo() +{ + // Recreate nodes in project + scene + for (const auto& sn : _nodes) { + auto node = _project->library->createNode(sn.typeName); + node->id = sn.id; + node->exportName = sn.exportName; + node->randomSeed = sn.randomSeed; + node->pos = sn.pos; + node->isDirty = true; + + for (auto key : sn.props.keys()) { + auto prop = node->getProp(key); + if (prop) + prop->fromJsonValue(sn.props[key]); + } + + _project->addNode(node); + addNodeToScene(_scene, node); + } + + // Recreate connections in project + scene + for (const auto& sc : _connections) { + auto leftNode = _project->getNodeById(sc.leftNodeId); + auto rightNode = _project->getNodeById(sc.rightNodeId); + if (!leftNode || !rightNode) + continue; + + _project->addConnection(leftNode, rightNode, sc.rightInput); + rightNode->isDirty = true; + + auto leftGNode = _scene->getNodeById(sc.leftNodeId); + auto rightGNode = _scene->getNodeById(sc.rightNodeId); + if (leftGNode && rightGNode) + _scene->connectNodes(leftGNode, sc.leftOutput, rightGNode, sc.rightInput); + } + + // Recreate frames + for (const auto& sf : _frames) { + auto modelFrame = FramePtr(new Frame()); + modelFrame->id = sf.id; + modelFrame->text = sf.title; + modelFrame->color = sf.color; + modelFrame->pos = sf.pos; + modelFrame->size = sf.size; + _project->frames[sf.id] = modelFrame; + + auto gframe = nodegraph::Frame::create(); + gframe->setId(sf.id); + gframe->setTitle(sf.title); + gframe->setColor(sf.color); + gframe->setPos(sf.pos.x(), sf.pos.y()); + if (sf.size.x() > 0 && sf.size.y() > 0) + gframe->setSize(sf.size.x(), sf.size.y()); + _scene->addFrame(gframe); + } + + // Recreate comments + for (const auto& sc : _comments) { + auto modelComment = CommentPtr(new Comment()); + modelComment->id = sc.id; + modelComment->text = sc.text; + modelComment->pos = sc.pos; + _project->comments[sc.id] = modelComment; + + auto gcomment = nodegraph::Comment::create(); + gcomment->setId(sc.id); + gcomment->setText(sc.text); + gcomment->setPos(sc.pos.x(), sc.pos.y()); + _scene->addComment(gcomment); + } + + if (_renderer) + _renderer->update(); +} + +// --------------------------------------------------------------------------- +// AddConnectionCommand +// --------------------------------------------------------------------------- + +AddConnectionCommand::AddConnectionCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& leftNodeId, + const QString& leftOutput, + const QString& rightNodeId, + const QString& rightInput) + : QUndoCommand(QString("Connect Nodes")) + , _project(project) + , _scene(scene) + , _renderer(renderer) + , _leftNodeId(leftNodeId) + , _leftOutput(leftOutput) + , _rightNodeId(rightNodeId) + , _rightInput(rightInput) +{} + +void AddConnectionCommand::redo() +{ + if (_firstRedo) { + // Scene already has the connection — just add to project model + auto left = _project->getNodeById(_leftNodeId); + auto right = _project->getNodeById(_rightNodeId); + if (left && right) { + _project->addConnection(left, right, _rightInput); + right->isDirty = true; + } + _firstRedo = false; + } else { + // Recreate in scene + project + auto leftG = _scene->getNodeById(_leftNodeId); + auto rightG = _scene->getNodeById(_rightNodeId); + if (leftG && rightG) + _scene->connectNodes(leftG, _leftOutput, rightG, _rightInput); + + auto left = _project->getNodeById(_leftNodeId); + auto right = _project->getNodeById(_rightNodeId); + if (left && right) { + _project->addConnection(left, right, _rightInput); + right->isDirty = true; + } + } + if (_renderer) + _renderer->update(); +} + +void AddConnectionCommand::undo() +{ + // Remove from scene + auto rightG = _scene->getNodeById(_rightNodeId); + if (rightG) { + auto port = rightG->getInPortByName(_rightInput); + if (port && !port->connections.isEmpty()) + _scene->removeConnection(port->connections.first()); + } + // Remove from project + auto right = _project->getNodeById(_rightNodeId); + if (right) + right->isDirty = true; + _project->removeConnection(_leftNodeId, _rightNodeId, _rightInput); + if (_renderer) + _renderer->update(); +} + +// --------------------------------------------------------------------------- +// RemoveConnectionCommand +// --------------------------------------------------------------------------- + +RemoveConnectionCommand::RemoveConnectionCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& leftNodeId, + const QString& leftOutput, + const QString& rightNodeId, + const QString& rightInput) + : QUndoCommand("Remove Connection") + , _project(project) + , _scene(scene) + , _renderer(renderer) + , _leftNodeId(leftNodeId) + , _leftOutput(leftOutput) + , _rightNodeId(rightNodeId) + , _rightInput(rightInput) +{} + +void RemoveConnectionCommand::redo() +{ + if (_firstRedo) { + // Scene already removed it — just remove from project model + auto con = _project->removeConnection(_leftNodeId, _rightNodeId, _rightInput); + if (con && con->rightNode) + con->rightNode->isDirty = true; + _firstRedo = false; + } else { + // Remove from scene + auto rightG = _scene->getNodeById(_rightNodeId); + if (rightG) { + auto port = rightG->getInPortByName(_rightInput); + if (port && !port->connections.isEmpty()) + _scene->removeConnection(port->connections.first()); + } + // Remove from project + auto con = _project->removeConnection(_leftNodeId, _rightNodeId, _rightInput); + if (con && con->rightNode) + con->rightNode->isDirty = true; + } + if (_renderer) + _renderer->update(); +} + +void RemoveConnectionCommand::undo() +{ + // Restore in scene + auto leftG = _scene->getNodeById(_leftNodeId); + auto rightG = _scene->getNodeById(_rightNodeId); + if (leftG && rightG) + _scene->connectNodes(leftG, _leftOutput, rightG, _rightInput); + + // Restore in project + auto left = _project->getNodeById(_leftNodeId); + auto right = _project->getNodeById(_rightNodeId); + if (left && right) { + _project->addConnection(left, right, _rightInput); + right->isDirty = true; + } + if (_renderer) + _renderer->update(); +} + +// --------------------------------------------------------------------------- +// MoveItemsCommand +// --------------------------------------------------------------------------- + +MoveItemsCommand::MoveItemsCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QMap& oldPositions, + const QMap& newPositions) + : QUndoCommand("Move Nodes") + , _project(project) + , _scene(scene) + , _oldPositions(oldPositions) + , _newPositions(newPositions) +{} + +bool MoveItemsCommand::mergeWith(const QUndoCommand* other) +{ + auto* cmd = static_cast(other); + if (cmd->_newPositions.keys() != _newPositions.keys()) + return false; + _newPositions = cmd->_newPositions; + return true; +} + +void MoveItemsCommand::applyPositions(const QMap& positions) +{ + for (auto it = positions.begin(); it != positions.end(); ++it) { + auto gnode = _scene->getNodeById(it.key()); + if (gnode) + gnode->setCenter(it.value().x(), it.value().y()); + auto node = _project->getNodeById(it.key()); + if (node) + node->pos = QVector2D(it.value()); + } +} + +void MoveItemsCommand::redo() +{ + applyPositions(_newPositions); +} + +void MoveItemsCommand::undo() +{ + applyPositions(_oldPositions); +} + +// --------------------------------------------------------------------------- +// PropertyChangeCommand +// --------------------------------------------------------------------------- + +PropertyChangeCommand::PropertyChangeCommand(TextureNodePtr node, + TextureProjectPtr project, + TextureRenderer* renderer, + const QString& propName, + QVariant oldValue, + QVariant newValue) + : QUndoCommand(QString("Change %1").arg(propName)) + , _node(node) + , _project(project) + , _renderer(renderer) + , _propName(propName) + , _oldValue(oldValue) + , _newValue(newValue) +{} + +bool PropertyChangeCommand::mergeWith(const QUndoCommand* other) +{ + auto* cmd = static_cast(other); + if (cmd->_node != _node || cmd->_propName != _propName) + return false; + _newValue = cmd->_newValue; + return true; +} + +void PropertyChangeCommand::applyValue(const QVariant& value) +{ + _node->setProp(_propName, value); + _project->markNodeAsDirty(_node); + if (_renderer) + _renderer->update(); +} + +void PropertyChangeCommand::redo() +{ + if (_firstRedo) { + _firstRedo = false; + return; // already applied by the signal handler + } + applyValue(_newValue); +} + +void PropertyChangeCommand::undo() +{ + applyValue(_oldValue); +} + +// --------------------------------------------------------------------------- +// RandomSeedChangeCommand +// --------------------------------------------------------------------------- + +RandomSeedChangeCommand::RandomSeedChangeCommand(TextureNodePtr node, + TextureProjectPtr project, + TextureRenderer* renderer, + long oldSeed, + long newSeed) + : QUndoCommand("Change Random Seed") + , _node(node) + , _project(project) + , _renderer(renderer) + , _oldSeed(oldSeed) + , _newSeed(newSeed) +{} + +void RandomSeedChangeCommand::redo() +{ + if (_firstRedo) { + _firstRedo = false; + return; + } + _node->randomSeed = _newSeed; + _project->markNodeAsDirty(_node); + if (_renderer) + _renderer->update(); +} + +void RandomSeedChangeCommand::undo() +{ + _node->randomSeed = _oldSeed; + _project->markNodeAsDirty(_node); + if (_renderer) + _renderer->update(); +} + +// --------------------------------------------------------------------------- +// AddFrameCommand +// --------------------------------------------------------------------------- + +AddFrameCommand::AddFrameCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QString& frameId, + QVector2D pos) + : QUndoCommand("Add Frame") + , _project(project) + , _scene(scene) + , _frameId(frameId) + , _pos(pos) +{} + +void AddFrameCommand::redo() +{ + auto modelFrame = FramePtr(new Frame()); + modelFrame->id = _frameId; + modelFrame->pos = _pos; + _project->frames[_frameId] = modelFrame; + + auto gframe = nodegraph::Frame::create(); + gframe->setId(_frameId); + gframe->setPos(_pos.x(), _pos.y()); + _scene->addFrame(gframe); +} + +void AddFrameCommand::undo() +{ + auto gframe = _scene->getFrameById(_frameId); + if (gframe) + _scene->removeFrame(gframe); + _project->frames.remove(_frameId); +} + +// --------------------------------------------------------------------------- +// AddCommentCommand +// --------------------------------------------------------------------------- + +AddCommentCommand::AddCommentCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QString& commentId, + QVector2D pos) + : QUndoCommand("Add Comment") + , _project(project) + , _scene(scene) + , _commentId(commentId) + , _pos(pos) +{} + +void AddCommentCommand::redo() +{ + auto modelComment = CommentPtr(new Comment()); + modelComment->id = _commentId; + modelComment->pos = _pos; + _project->comments[_commentId] = modelComment; + + auto gcomment = nodegraph::Comment::create(); + gcomment->setId(_commentId); + gcomment->setPos(_pos.x(), _pos.y()); + _scene->addComment(gcomment); +} + +void AddCommentCommand::undo() +{ + auto gcomment = _scene->getCommentById(_commentId); + if (gcomment) + _scene->removeComment(gcomment); + _project->comments.remove(_commentId); +} + +// --------------------------------------------------------------------------- +// EditFrameCommand +// --------------------------------------------------------------------------- + +EditFrameCommand::EditFrameCommand(FramePtr frame, + nodegraph::ScenePtr scene, + const QString& oldTitle, const QColor& oldColor, + const QString& newTitle, const QColor& newColor) + : QUndoCommand("Edit Frame") + , _frame(frame) + , _scene(scene) + , _frameId(frame->id) + , _oldTitle(oldTitle), _newTitle(newTitle) + , _oldColor(oldColor), _newColor(newColor) +{} + +bool EditFrameCommand::mergeWith(const QUndoCommand* other) +{ + auto* cmd = static_cast(other); + if (cmd->_frameId != _frameId) + return false; + _newTitle = cmd->_newTitle; + _newColor = cmd->_newColor; + return true; +} + +void EditFrameCommand::apply(const QString& title, const QColor& color) +{ + _frame->text = title; + _frame->color = color; + auto gframe = _scene->getFrameById(_frameId); + if (gframe) { + gframe->setTitle(title); + gframe->setColor(color); + } +} + +void EditFrameCommand::redo() +{ + if (_firstRedo) { _firstRedo = false; return; } + apply(_newTitle, _newColor); +} + +void EditFrameCommand::undo() +{ + apply(_oldTitle, _oldColor); +} + +// --------------------------------------------------------------------------- +// EditCommentCommand +// --------------------------------------------------------------------------- + +EditCommentCommand::EditCommentCommand(CommentPtr comment, + nodegraph::ScenePtr scene, + const QString& oldText, + const QString& newText) + : QUndoCommand("Edit Comment") + , _comment(comment) + , _scene(scene) + , _commentId(comment->id) + , _oldText(oldText) + , _newText(newText) +{} + +bool EditCommentCommand::mergeWith(const QUndoCommand* other) +{ + auto* cmd = static_cast(other); + if (cmd->_commentId != _commentId) + return false; + _newText = cmd->_newText; + return true; +} + +void EditCommentCommand::apply(const QString& text) +{ + _comment->text = text; + auto gcomment = _scene->getCommentById(_commentId); + if (gcomment) + gcomment->setText(text); +} + +void EditCommentCommand::redo() +{ + if (_firstRedo) { _firstRedo = false; return; } + apply(_newText); +} + +void EditCommentCommand::undo() +{ + apply(_oldText); +} + +// --------------------------------------------------------------------------- +// PasteCommand +// --------------------------------------------------------------------------- + +PasteCommand::PasteCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + QPointF viewCenter) + : QUndoCommand() + , _project(project) + , _scene(scene) + , _renderer(renderer) +{ + if (!Clipboard::pasteItems(project, viewCenter, + _nodes, _connections, _comments, _frames)) + return; // nothing to paste + + int total = _nodes.size() + _frames.size() + _comments.size(); + setText(QString("Paste %1 Item%2").arg(total).arg(total == 1 ? "" : "s")); +} + +bool PasteCommand::isEmpty() const +{ + return _nodes.isEmpty() && _frames.isEmpty() && _comments.isEmpty(); +} + +void PasteCommand::addNodeToScene(const TextureNodePtr& node) +{ + auto gnode = nodegraph::Node::create(); + gnode->setId(node->id); + gnode->setName(node->title); + for (auto& input : node->inputs) + gnode->addInPort(input); + gnode->addOutPort("output"); + gnode->setCenter(node->pos.x(), node->pos.y()); + _scene->addNode(gnode); +} + +void PasteCommand::redo() +{ + _scene->clearSelection(); + + for (auto& node : _nodes) { + _project->nodes[node->id] = node; + addNodeToScene(node); + auto gnode = _scene->getNodeById(node->id); + if (gnode) + gnode->setSelected(true); + } + + for (auto& con : _connections) { + _project->connections[con->id] = con; + con->rightNode->isDirty = true; + auto leftG = _scene->getNodeById(con->leftNode->id); + auto rightG = _scene->getNodeById(con->rightNode->id); + if (leftG && rightG) + _scene->connectNodes(leftG, "output", rightG, con->rightNodeInputName); + } + + for (auto& comment : _comments) { + _project->comments[comment->id] = comment; + auto gc = nodegraph::Comment::create(); + gc->setId(comment->id); + gc->setText(comment->text); + gc->setPos(comment->pos.x(), comment->pos.y()); + _scene->addComment(gc); + gc->setSelected(true); + } + + for (auto& frame : _frames) { + _project->frames[frame->id] = frame; + auto gf = nodegraph::Frame::create(); + gf->setId(frame->id); + gf->setTitle(frame->text); + gf->setColor(frame->color); + gf->setPos(frame->pos.x(), frame->pos.y()); + if (frame->size.x() > 0 && frame->size.y() > 0) + gf->setSize(frame->size.x(), frame->size.y()); + _scene->addFrame(gf); + gf->setSelected(true); + } + + if (_renderer) + _renderer->update(); +} + +void PasteCommand::undo() +{ + for (auto& con : _connections) + _project->connections.remove(con->id); + + for (auto& node : _nodes) { + auto gnode = _scene->getNodeById(node->id); + if (gnode) + _scene->removeNode(gnode); + _project->nodes.remove(node->id); + } + + for (auto& comment : _comments) { + auto gc = _scene->getCommentById(comment->id); + if (gc) + _scene->removeComment(gc); + _project->comments.remove(comment->id); + } + + for (auto& frame : _frames) { + auto gf = _scene->getFrameById(frame->id); + if (gf) + _scene->removeFrame(gf); + _project->frames.remove(frame->id); + } + + if (_renderer) + _renderer->update(); +} + +// --------------------------------------------------------------------------- +// TextureChannelAssignCommand +// --------------------------------------------------------------------------- + +TextureChannelAssignCommand::TextureChannelAssignCommand( + TextureProjectPtr project, + TextureChannel channel, + const QString& oldNodeId, + const QString& newNodeId, + std::function syncViewer) + : QUndoCommand("Assign Texture Channel") + , _project(project) + , _channel(channel) + , _oldNodeId(oldNodeId) + , _newNodeId(newNodeId) + , _syncViewer(syncViewer) +{} + +void TextureChannelAssignCommand::apply(const QString& nodeId) +{ + if (nodeId.isEmpty()) + _project->textureChannels.remove(_channel); + else + _project->textureChannels[_channel] = nodeId; + if (_syncViewer) + _syncViewer(); +} + +void TextureChannelAssignCommand::redo() +{ + if (_firstRedo) { _firstRedo = false; return; } + apply(_newNodeId); +} + +void TextureChannelAssignCommand::undo() +{ + apply(_oldNodeId); +} diff --git a/src/texturelab/undo/undocommands.h b/src/texturelab/undo/undocommands.h new file mode 100644 index 00000000..e2a950bc --- /dev/null +++ b/src/texturelab/undo/undocommands.h @@ -0,0 +1,16 @@ +#pragma once + +#include "addcommentcommand.h" +#include "addconnectioncommand.h" +#include "addframecommand.h" +#include "addnodecommand.h" +#include "deleteitemscommand.h" +#include "editcommentcommand.h" +#include "editframecommand.h" +#include "moveitemscommand.h" +#include "pastecommand.h" +#include "propertychangecommand.h" +#include "randomseedchangecommand.h" +#include "removeconnectioncommand.h" +#include "texturechannelassigncommand.h" +#include "undocommandids.h" diff --git a/src/texturelab/widgets/graphwidget.cpp b/src/texturelab/widgets/graphwidget.cpp index 1d9c319a..7cd70f8b 100644 --- a/src/texturelab/widgets/graphwidget.cpp +++ b/src/texturelab/widgets/graphwidget.cpp @@ -1,5 +1,7 @@ #include "graphwidget.h" #include "../clipboard.h" +#include "../undo/undocommands.h" +#include #include #include #include @@ -71,40 +73,40 @@ GraphWidget::GraphWidget() : QMainWindow(nullptr) connect(graph, &nodegraph::NodeGraph::connectionAdded, [=](nodegraph::ConnectionPtr con) { - qDebug() << "CONNECTION ADDED"; - - // auto sceneCon = project->getConnectionById(con->id()); - // sceneCon->rightNode->isDirty = true; - - auto leftNode = - project->getNodeById(con->startPort->node->id()); - auto rightNode = project->getNodeById(con->endPort->node->id()); - auto rightName = con->endPort->name; - - project->addConnection(leftNode, rightNode, rightName); - - // make ready for update - rightNode->isDirty = true; - - // todo: try to update later - renderer->update(); + auto leftNodeId = con->startPort->node->id(); + auto leftOutput = con->startPort->name; + auto rightNodeId = con->endPort->node->id(); + auto rightInput = con->endPort->name; + if (undoStack) + undoStack->push(new AddConnectionCommand( + project, scene, renderer, + leftNodeId, leftOutput, rightNodeId, rightInput)); + else { + project->addConnection( + project->getNodeById(leftNodeId), + project->getNodeById(rightNodeId), rightInput); + project->getNodeById(rightNodeId)->isDirty = true; + renderer->update(); + } }); connect(graph, &nodegraph::NodeGraph::connectionRemoved, [=](nodegraph::ConnectionPtr con) { - qDebug() << "CONNECTION REMOVED"; - - auto leftNodeId = con->startPort->node->id(); + auto leftNodeId = con->startPort->node->id(); + auto leftOutput = con->startPort->name; auto rightNodeId = con->endPort->node->id(); - auto portName = con->endPort->name; - - auto removedCon = project->removeConnection( - leftNodeId, rightNodeId, portName); - - removedCon->rightNode->isDirty = true; - - // todo: try to update later - renderer->update(); + auto rightInput = con->endPort->name; + if (undoStack) + undoStack->push(new RemoveConnectionCommand( + project, scene, renderer, + leftNodeId, leftOutput, rightNodeId, rightInput)); + else { + auto con2 = project->removeConnection( + leftNodeId, rightNodeId, rightInput); + if (con2 && con2->rightNode) + con2->rightNode->isDirty = true; + renderer->update(); + } }); connect(graph, &nodegraph::NodeGraph::nodeSelectionChanged, @@ -139,27 +141,44 @@ GraphWidget::GraphWidget() : QMainWindow(nullptr) } }); - connect(graph, &nodegraph::NodeGraph::nodeRemoved, - [=](nodegraph::NodePtr node) { - auto nodeId = node->id(); - auto texNode = project->getNodeById(nodeId); - - // remove all connections involving this node from the model - for (auto key : project->connections.keys()) { - auto con = project->connections[key]; - if (con->leftNode->id == nodeId || - con->rightNode->id == nodeId) { - // mark downstream node dirty before disconnecting - if (con->leftNode->id == nodeId) - con->rightNode->isDirty = true; - project->connections.remove(key); + connect(graph, &nodegraph::NodeGraph::deleteRequested, + [=](QList nodes, + QList frames, + QList comments) { + QList nodeIds, frameIds, commentIds; + for (auto& n : nodes) nodeIds.append(n->id()); + for (auto& f : frames) frameIds.append(f->id()); + for (auto& c : comments) commentIds.append(c->id()); + if (undoStack) + undoStack->push(new DeleteItemsCommand( + project, scene, renderer, nodeIds, frameIds, commentIds)); + else { + // Fallback: direct deletion (no undo) + for (auto& n : nodes) { + scene->removeNode(n); + for (auto key : project->connections.keys()) { + auto con = project->connections.value(key); + if (con->leftNode->id == n->id() || con->rightNode->id == n->id()) { + if (con->leftNode->id == n->id()) + con->rightNode->isDirty = true; + project->connections.remove(key); + } + } + project->nodes.remove(n->id()); } + for (auto& f : frames) { scene->removeFrame(f); project->frames.remove(f->id()); } + for (auto& c : comments) { scene->removeComment(c); project->comments.remove(c->id()); } + renderer->update(); } - - project->nodes.remove(nodeId); - emit nodeSelectionChanged(TextureNodePtr(nullptr)); - renderer->update(); + emit frameSelectionChanged(FramePtr(nullptr)); + emit commentSelectionChanged(CommentPtr(nullptr)); + }); + + connect(graph, &nodegraph::NodeGraph::itemsMoveFinished, + [=](QMap oldPos, QMap newPos) { + if (undoStack) + undoStack->push(new MoveItemsCommand(project, scene, oldPos, newPos)); }); connect(graph, &nodegraph::NodeGraph::frameSelectionChanged, @@ -378,37 +397,51 @@ void GraphWidget::dropEvent(QDropEvent* evt) auto scenePos = this->graph->mapToScene(evt->position().toPoint()); if (data->itemType == PopupItemType::Frame) { - auto frame = nodegraph::Frame::create(); - frame->setPos(scenePos); - scene->addFrame(frame); - - if (project) { - auto modelFrame = FramePtr(new Frame()); - modelFrame->id = frame->id(); - modelFrame->text = frame->title(); - modelFrame->pos = QVector2D(scenePos.x(), scenePos.y()); - project->frames[modelFrame->id] = modelFrame; + QString frameId = QUuid::createUuid().toString(QUuid::WithoutBraces); + if (undoStack) + undoStack->push(new AddFrameCommand( + project, scene, frameId, QVector2D(scenePos))); + else { + auto frame = nodegraph::Frame::create(); + frame->setPos(scenePos); + scene->addFrame(frame); + if (project) { + auto modelFrame = FramePtr(new Frame()); + modelFrame->id = frame->id(); + modelFrame->pos = QVector2D(scenePos); + project->frames[modelFrame->id] = modelFrame; + } } } else if (data->itemType == PopupItemType::Comment) { - auto comment = nodegraph::Comment::create(); - comment->setPos(scenePos); - scene->addComment(comment); - - if (project) { - auto modelComment = CommentPtr(new Comment()); - modelComment->id = comment->id(); - modelComment->text = comment->text(); - modelComment->pos = QVector2D(scenePos.x(), scenePos.y()); - project->comments[modelComment->id] = modelComment; + QString commentId = QUuid::createUuid().toString(QUuid::WithoutBraces); + if (undoStack) + undoStack->push(new AddCommentCommand( + project, scene, commentId, QVector2D(scenePos))); + else { + auto comment = nodegraph::Comment::create(); + comment->setPos(scenePos); + scene->addComment(comment); + if (project) { + auto modelComment = CommentPtr(new Comment()); + modelComment->id = comment->id(); + modelComment->pos = QVector2D(scenePos); + project->comments[modelComment->id] = modelComment; + } } } else { - auto node = project->library->createNode(data->libraryItemName); - node->pos = QVector2D(scenePos); - this->project->addNode(node); - this->addNode(node); - this->renderer->update(); + if (undoStack) + undoStack->push(new AddNodeCommand( + project, scene, renderer, + data->libraryItemName, QVector2D(scenePos))); + else { + auto node = project->library->createNode(data->libraryItemName); + node->pos = QVector2D(scenePos); + project->addNode(node); + addNode(node); + renderer->update(); + } } evt->accept(); @@ -482,72 +515,55 @@ void GraphWidget::executeCut() executeCopy(); - // Collect IDs before modifying the scene QList nodeIds, frameIds, commentIds; for (auto item : scene->selectedItems()) { if (item->type() == (int)nodegraph::SceneItemType::Node) { auto node = qgraphicsitem_cast(item); - if (node) - nodeIds.append(node->id()); + if (node) nodeIds.append(node->id()); } else if (item->type() == (int)nodegraph::SceneItemType::Frame) { auto frame = qgraphicsitem_cast(item); - if (frame) - frameIds.append(frame->id()); + if (frame) frameIds.append(frame->id()); } else if (item->type() == (int)nodegraph::SceneItemType::Comment) { auto comment = qgraphicsitem_cast(item); - if (comment) - commentIds.append(comment->id()); + if (comment) commentIds.append(comment->id()); } } - // Remove nodes, propagating dirty through the full downstream subgraph - for (const auto& id : nodeIds) { - auto ngNode = scene->getNodeById(id); - if (ngNode) - scene->removeNode(ngNode); - - // Capture downstream nodes before their connections are removed - auto downstream = project->getNodeRightOfNode(id); + if (nodeIds.isEmpty() && frameIds.isEmpty() && commentIds.isEmpty()) + return; - for (auto key : project->connections.keys()) { - auto con = project->connections[key]; - if (con->leftNode->id == id || con->rightNode->id == id) - project->connections.remove(key); + if (undoStack) + undoStack->push(new DeleteItemsCommand( + project, scene, renderer, nodeIds, frameIds, commentIds)); + else { + for (const auto& id : nodeIds) { + auto ngNode = scene->getNodeById(id); + if (ngNode) scene->removeNode(ngNode); + for (auto key : project->connections.keys()) { + auto con = project->connections.value(key); + if (con->leftNode->id == id || con->rightNode->id == id) + project->connections.remove(key); + } + project->nodes.remove(id); } - - // BFS-mark all transitive dependents dirty so they re-render - for (auto& dep : downstream) - project->markNodeAsDirty(dep); - - project->nodes.remove(id); - } - - // Remove frames - for (const auto& id : frameIds) { - auto ngFrame = scene->getFrameById(id); - if (ngFrame) - scene->removeFrame(ngFrame); - project->frames.remove(id); - } - - // Remove comments - for (const auto& id : commentIds) { - auto ngComment = scene->getCommentById(id); - if (ngComment) - scene->removeComment(ngComment); - project->comments.remove(id); + for (const auto& id : frameIds) { + auto f = scene->getFrameById(id); + if (f) scene->removeFrame(f); + project->frames.remove(id); + } + for (const auto& id : commentIds) { + auto c = scene->getCommentById(id); + if (c) scene->removeComment(c); + project->comments.remove(id); + } + if (renderer) renderer->update(); } - // Clear properties panel regardless of which item type was selected emit nodeSelectionChanged(TextureNodePtr(nullptr)); emit frameSelectionChanged(FramePtr(nullptr)); emit commentSelectionChanged(CommentPtr(nullptr)); - - scene->update(); - if (renderer) - renderer->update(); } void GraphWidget::executePaste() @@ -555,67 +571,59 @@ void GraphWidget::executePaste() if (!project || !scene) return; - QList newNodes; - QList newConnections; - QList newComments; - QList newFrames; - QPointF viewCenter = graph->mapToScene(graph->viewport()->rect().center()); - if (!Clipboard::pasteItems(project, viewCenter, newNodes, newConnections, - newComments, newFrames)) - return; - - scene->clearSelection(); - - // Add nodes - for (auto& node : newNodes) { - project->nodes[node->id] = node; - addNode(node); - auto ngNode = scene->getNodeById(node->id); - if (ngNode) - ngNode->setSelected(true); - } - - // Add connections and invalidate the receiving node so it re-renders - for (auto& con : newConnections) { - project->connections[con->id] = con; - con->rightNode->isDirty = true; - auto leftNgNode = scene->getNodeById(con->leftNode->id); - auto rightNgNode = scene->getNodeById(con->rightNode->id); - if (leftNgNode && rightNgNode) - scene->connectNodes(leftNgNode, "output", rightNgNode, - con->rightNodeInputName); - } - - // Add comments - for (auto& comment : newComments) { - project->comments[comment->id] = comment; - auto gcomment = nodegraph::Comment::create(); - gcomment->setId(comment->id); - gcomment->setText(comment->text); - gcomment->setPos(comment->pos.x(), comment->pos.y()); - scene->addComment(gcomment); - gcomment->setSelected(true); - } + if (undoStack) { + auto* cmd = new PasteCommand(project, scene, renderer, viewCenter); + if (cmd->isEmpty()) { delete cmd; return; } + undoStack->push(cmd); + } else { + QList newNodes; + QList newConnections; + QList newComments; + QList newFrames; + + if (!Clipboard::pasteItems(project, viewCenter, newNodes, newConnections, + newComments, newFrames)) + return; - // Add frames - for (auto& frame : newFrames) { - project->frames[frame->id] = frame; - auto gframe = nodegraph::Frame::create(); - gframe->setId(frame->id); - gframe->setTitle(frame->text); - gframe->setColor(frame->color); - gframe->setPos(frame->pos.x(), frame->pos.y()); - if (frame->size.x() > 0 && frame->size.y() > 0) - gframe->setSize(frame->size.x(), frame->size.y()); - scene->addFrame(gframe); - gframe->setSelected(true); + scene->clearSelection(); + for (auto& node : newNodes) { + project->nodes[node->id] = node; + addNode(node); + auto ngNode = scene->getNodeById(node->id); + if (ngNode) ngNode->setSelected(true); + } + for (auto& con : newConnections) { + project->connections[con->id] = con; + con->rightNode->isDirty = true; + auto l = scene->getNodeById(con->leftNode->id); + auto r = scene->getNodeById(con->rightNode->id); + if (l && r) scene->connectNodes(l, "output", r, con->rightNodeInputName); + } + for (auto& comment : newComments) { + project->comments[comment->id] = comment; + auto gc = nodegraph::Comment::create(); + gc->setId(comment->id); gc->setText(comment->text); + gc->setPos(comment->pos.x(), comment->pos.y()); + scene->addComment(gc); gc->setSelected(true); + } + for (auto& frame : newFrames) { + project->frames[frame->id] = frame; + auto gf = nodegraph::Frame::create(); + gf->setId(frame->id); gf->setTitle(frame->text); gf->setColor(frame->color); + gf->setPos(frame->pos.x(), frame->pos.y()); + if (frame->size.x() > 0 && frame->size.y() > 0) + gf->setSize(frame->size.x(), frame->size.y()); + scene->addFrame(gf); gf->setSelected(true); + } + if (renderer) renderer->update(); } +} - scene->update(); - if (renderer) - renderer->update(); +void GraphWidget::setUndoStack(QUndoStack* stack) +{ + undoStack = stack; } void GraphWidget::addItemFromSearch(const QString& name, PopupItemType type, @@ -625,42 +633,52 @@ void GraphWidget::addItemFromSearch(const QString& name, PopupItemType type, auto scenePos = graph->mapToScene(localPos); if (type == PopupItemType::Frame) { - auto frame = nodegraph::Frame::create(); - frame->setPos(scenePos); - scene->addFrame(frame); - - if (project) { - auto modelFrame = FramePtr(new Frame()); - modelFrame->id = frame->id(); - modelFrame->text = frame->title(); - modelFrame->pos = QVector2D(scenePos.x(), scenePos.y()); - project->frames[modelFrame->id] = modelFrame; + QString frameId = QUuid::createUuid().toString(QUuid::WithoutBraces); + if (undoStack) + undoStack->push(new AddFrameCommand( + project, scene, frameId, QVector2D(scenePos))); + else { + auto frame = nodegraph::Frame::create(); + frame->setPos(scenePos); + scene->addFrame(frame); + if (project) { + auto modelFrame = FramePtr(new Frame()); + modelFrame->id = frame->id(); + modelFrame->pos = QVector2D(scenePos); + project->frames[modelFrame->id] = modelFrame; + } } } else if (type == PopupItemType::Comment) { - auto comment = nodegraph::Comment::create(); - comment->setPos(scenePos); - scene->addComment(comment); - - if (project) { - auto modelComment = CommentPtr(new Comment()); - modelComment->id = comment->id(); - modelComment->text = comment->text(); - modelComment->pos = QVector2D(scenePos.x(), scenePos.y()); - project->comments[modelComment->id] = modelComment; + QString commentId = QUuid::createUuid().toString(QUuid::WithoutBraces); + if (undoStack) + undoStack->push(new AddCommentCommand( + project, scene, commentId, QVector2D(scenePos))); + else { + auto comment = nodegraph::Comment::create(); + comment->setPos(scenePos); + scene->addComment(comment); + if (project) { + auto modelComment = CommentPtr(new Comment()); + modelComment->id = comment->id(); + modelComment->pos = QVector2D(scenePos); + project->comments[modelComment->id] = modelComment; + } } } else { if (!project || !project->library) return; - auto node = project->library->createNode(name); - node->pos = QVector2D(scenePos); - this->project->addNode(node); - this->addNode(node); - - if (this->renderer) { - this->renderer->update(); + if (undoStack) + undoStack->push(new AddNodeCommand( + project, scene, renderer, name, QVector2D(scenePos))); + else { + auto node = project->library->createNode(name); + node->pos = QVector2D(scenePos); + project->addNode(node); + addNode(node); + if (renderer) renderer->update(); } } } \ No newline at end of file diff --git a/src/texturelab/widgets/graphwidget.h b/src/texturelab/widgets/graphwidget.h index 2712761d..d0407dd0 100644 --- a/src/texturelab/widgets/graphwidget.h +++ b/src/texturelab/widgets/graphwidget.h @@ -5,6 +5,7 @@ #include #include #include +#include class QDragEnterEvent; class TextureRenderer; @@ -33,6 +34,7 @@ class GraphWidget : public QMainWindow { GraphWidget(); void setTextureProject(TextureProjectPtr project); + void setUndoStack(QUndoStack* stack); void dragEnterEvent(QDragEnterEvent* evt); void dragMoveEvent(QDragMoveEvent* event); @@ -51,6 +53,7 @@ class GraphWidget : public QMainWindow { TextureProjectPtr project; TextureRenderer* renderer; + QUndoStack* undoStack = nullptr; protected: void addNode(const TextureNodePtr& node); diff --git a/src/texturelab/widgets/properties/propertieswidget.cpp b/src/texturelab/widgets/properties/propertieswidget.cpp index 8d3d1c1a..14604280 100644 --- a/src/texturelab/widgets/properties/propertieswidget.cpp +++ b/src/texturelab/widgets/properties/propertieswidget.cpp @@ -1,6 +1,7 @@ #include "propertieswidget.h" #include "../../models.h" #include "../../props.h" +#include "../../undo/undocommands.h" #include "accordionwidget.h" #include "curvepropwidget.h" #include "propwidgets.h" @@ -30,6 +31,21 @@ PropertiesWidget::PropertiesWidget() : QWidget() this->setLayout(layout); } +// Helper: push PropertyChangeCommand if undoStack is set; otherwise apply directly. +// The value is applied before calling this (first-redo pattern). +static void pushPropChange(QUndoStack* stack, TextureNodePtr node, + TextureProjectPtr project, + TextureRenderer* /*renderer*/, + const QString& propName, + QVariant oldVal, QVariant newVal) +{ + if (stack) + stack->push(new PropertyChangeCommand( + node, project, nullptr, propName, oldVal, newVal)); + // renderer=nullptr: PropertiesWidget doesn't hold the renderer; + // markNodeAsDirty already triggers re-render via the renderer's update loop. +} + QWidget* PropertiesWidget::createPropWidget(Prop* prop, const TextureNodePtr& node) { @@ -39,9 +55,11 @@ QWidget* PropertiesWidget::createPropWidget(Prop* prop, widget->setProp((FloatProp*)prop); propWidgets.append(widget); connect(widget, &FloatPropWidget::valueChanged, [=](double value) { + QVariant oldVal = prop->getValue(); node->setProp(prop->name, value); project->markNodeAsDirty(node); emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, value); }); return widget; } @@ -50,9 +68,11 @@ QWidget* PropertiesWidget::createPropWidget(Prop* prop, widget->setProp((BoolProp*)prop); propWidgets.append(widget); connect(widget, &BoolPropWidget::valueChanged, [=](bool value) { + QVariant oldVal = prop->getValue(); node->setProp(prop->name, value); project->markNodeAsDirty(node); emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, value); }); return widget; } @@ -61,9 +81,11 @@ QWidget* PropertiesWidget::createPropWidget(Prop* prop, widget->setProp((IntProp*)prop); propWidgets.append(widget); connect(widget, &IntPropWidget::valueChanged, [=](long value) { + QVariant oldVal = prop->getValue(); node->setProp(prop->name, (int)value); project->markNodeAsDirty(node); emit propertyUpdated(prop->name, (int)value); + pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, (int)value); }); return widget; } @@ -72,9 +94,11 @@ QWidget* PropertiesWidget::createPropWidget(Prop* prop, widget->setProp((EnumProp*)prop); propWidgets.append(widget); connect(widget, &EnumPropWidget::valueChanged, [=](int value) { + QVariant oldVal = prop->getValue(); node->setProp(prop->name, value); project->markNodeAsDirty(node); emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, value); }); return widget; } @@ -82,59 +106,66 @@ QWidget* PropertiesWidget::createPropWidget(Prop* prop, auto widget = new ColorPropWidget(); widget->setProp((ColorProp*)prop); propWidgets.append(widget); - connect(widget, &ColorPropWidget::valueChanged, - [=](const QColor& value) { - node->setProp(prop->name, value); - project->markNodeAsDirty(node); - emit propertyUpdated(prop->name, value); - }); + connect(widget, &ColorPropWidget::valueChanged, [=](const QColor& value) { + QVariant oldVal = prop->getValue(); + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, value); + }); return widget; } case PropType::Gradient: { auto widget = new GradientPropWidget(); widget->setProp((GradientProp*)prop); propWidgets.append(widget); - connect(widget, &GradientPropWidget::valueChanged, - [=](const Gradient& value) { - node->setProp(prop->name, QVariant::fromValue(value)); - project->markNodeAsDirty(node); - emit propertyUpdated(prop->name, QVariant::fromValue(value)); - }); + connect(widget, &GradientPropWidget::valueChanged, [=](const Gradient& value) { + QVariant oldVal = prop->getValue(); + QVariant newVal = QVariant::fromValue(value); + node->setProp(prop->name, newVal); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, newVal); + pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, newVal); + }); return widget; } case PropType::Image: { auto widget = new ImagePropWidget(); widget->setProp((ImageProp*)prop); propWidgets.append(widget); - connect(widget, &ImagePropWidget::valueChanged, - [=](const QImage& value) { - node->setProp(prop->name, value); - project->markNodeAsDirty(node); - emit propertyUpdated(prop->name, value); - }); + connect(widget, &ImagePropWidget::valueChanged, [=](const QImage& value) { + QVariant oldVal = prop->getValue(); + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, value); + }); return widget; } case PropType::String: { auto widget = new StringPropWidget(); widget->setProp((StringProp*)prop); propWidgets.append(widget); - connect(widget, &StringPropWidget::valueChanged, - [=](const QString& value) { - node->setProp(prop->name, value); - project->markNodeAsDirty(node); - emit propertyUpdated(prop->name, value); - }); + connect(widget, &StringPropWidget::valueChanged, [=](const QString& value) { + QVariant oldVal = prop->getValue(); + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, value); + }); return widget; } case PropType::Curve: { auto widget = new CurvePropWidget((CurveProp*)prop); propWidgets.append(widget); - connect(widget, &CurvePropWidget::valueChanged, - [=](const Curve& value) { - node->setProp(prop->name, QVariant::fromValue(value)); - project->markNodeAsDirty(node); - emit propertyUpdated(prop->name, QVariant::fromValue(value)); - }); + connect(widget, &CurvePropWidget::valueChanged, [=](const Curve& value) { + QVariant oldVal = prop->getValue(); + QVariant newVal = QVariant::fromValue(value); + node->setProp(prop->name, newVal); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, newVal); + pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, newVal); + }); return widget; } default: @@ -215,10 +246,13 @@ void PropertiesWidget::addBasePropsToLayout() auto seedWidget = new IntPropWidget(); seedWidget->setProp(randomSeedProp); connect(seedWidget, &IntPropWidget::valueChanged, [=](int value) { + long oldSeed = this->selectedNode->randomSeed; this->selectedNode->randomSeed = value; this->project->markNodeAsDirty(this->selectedNode); - emit this->propertyUpdated("randomSeed", value); + if (undoStack) + undoStack->push(new RandomSeedChangeCommand( + this->selectedNode, this->project, nullptr, oldSeed, value)); }); layout->addWidget(seedWidget); } @@ -246,8 +280,17 @@ void PropertiesWidget::setSelectedFrame(const FramePtr& frame) propWidgets.append(titleWidget); connect(titleWidget, &StringPropWidget::valueChanged, [=](const QString& value) { - frame->text = value; - emit framePropertyChanged(frame); + if (undoStack) { + QString oldTitle = frame->text; + QColor oldColor = frame->color; + frame->text = value; + emit framePropertyChanged(frame); + undoStack->push(new EditFrameCommand( + frame, scene, oldTitle, oldColor, value, oldColor)); + } else { + frame->text = value; + emit framePropertyChanged(frame); + } }); layout->addWidget(titleWidget); @@ -259,8 +302,17 @@ void PropertiesWidget::setSelectedFrame(const FramePtr& frame) propWidgets.append(colorWidget); connect(colorWidget, &ColorPropWidget::valueChanged, [=](const QColor& color) { - frame->color = color; - emit framePropertyChanged(frame); + if (undoStack) { + QString oldTitle = frame->text; + QColor oldColor = frame->color; + frame->color = color; + emit framePropertyChanged(frame); + undoStack->push(new EditFrameCommand( + frame, scene, oldTitle, oldColor, oldTitle, color)); + } else { + frame->color = color; + emit framePropertyChanged(frame); + } }); layout->addWidget(colorWidget); @@ -291,8 +343,15 @@ void PropertiesWidget::setSelectedComment(const CommentPtr& comment) propWidgets.append(textWidget); connect(textWidget, &StringPropWidget::valueChanged, [=](const QString& value) { - comment->text = value; - emit commentPropertyChanged(comment); + if (undoStack) { + QString oldText = comment->text; + comment->text = value; + emit commentPropertyChanged(comment); + undoStack->push(new EditCommentCommand(comment, scene, oldText, value)); + } else { + comment->text = value; + emit commentPropertyChanged(comment); + } }); layout->addWidget(textWidget); @@ -330,4 +389,14 @@ void PropertiesWidget::clearSelection() void PropertiesWidget::setProject(const TextureProjectPtr& project) { this->project = project; +} + +void PropertiesWidget::setScene(NgScenePtr ngScene) +{ + scene = ngScene; +} + +void PropertiesWidget::setUndoStack(QUndoStack* stack) +{ + undoStack = stack; } \ No newline at end of file diff --git a/src/texturelab/widgets/properties/propertieswidget.h b/src/texturelab/widgets/properties/propertieswidget.h index 29b9c741..a6255245 100644 --- a/src/texturelab/widgets/properties/propertieswidget.h +++ b/src/texturelab/widgets/properties/propertieswidget.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include @@ -12,6 +14,9 @@ typedef QSharedPointer TextureNodePtr; typedef QSharedPointer CommentPtr; typedef QSharedPointer FramePtr; +namespace nodegraph { class Scene; } +typedef QSharedPointer NgScenePtr; + class Prop; class EnumProp; class IntProp; @@ -28,6 +33,8 @@ class PropertiesWidget : public QWidget { QVector propWidgets; TextureProjectPtr project; + NgScenePtr scene; + QUndoStack* undoStack = nullptr; TextureNodePtr selectedNode; FramePtr selectedFrame; CommentPtr selectedComment; @@ -45,6 +52,8 @@ class PropertiesWidget : public QWidget { void clearSelection(); void setProject(const TextureProjectPtr& project); + void setScene(NgScenePtr ngScene); + void setUndoStack(QUndoStack* stack); private: void addBasePropsToLayout(); From 81a51b19f3be4d09c7752f3b39b0e67c8e9447c4 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 13 Jun 2026 23:30:06 -0500 Subject: [PATCH 089/164] add bricks2 node --- src/texturelab/CMakeLists.txt | 1 + src/texturelab/libraries/library.cpp | 2 + src/texturelab/libraries/libv3.h | 5 + src/texturelab/libraries/v3/bricks2.cpp | 264 ++++++++++++++++++++++++ 4 files changed, 272 insertions(+) create mode 100644 src/texturelab/libraries/v3/bricks2.cpp diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 479fd520..f0d81772 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -123,6 +123,7 @@ set(LIBRARYV3 ./libraries/v3/toongradient.cpp ./libraries/v3/autolevels.cpp # Phase 2 — Generators + ./libraries/v3/bricks2.cpp ./libraries/v3/directionalscratches.cpp ./libraries/v3/roughgrain.cpp ./libraries/v3/voronoifractal.cpp diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index 1e79f74e..aadd312f 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -253,6 +253,8 @@ Library* createLibraryV3() // ----------------------------------------------------------------------- // Phase 2 — Generators // ----------------------------------------------------------------------- + lib->addNode("bricks2", "Bricks 2", + ":nodes/brickgenerator.png"); lib->addNode( "directionalscratches", "Directional Scratches", ":nodes/cell.png"); lib->addNode("roughgrain", "Rough Grain", diff --git a/src/texturelab/libraries/libv3.h b/src/texturelab/libraries/libv3.h index db5eed95..89af8d49 100644 --- a/src/texturelab/libraries/libv3.h +++ b/src/texturelab/libraries/libv3.h @@ -49,6 +49,11 @@ class AutoLevelsNode : public TextureNode { // Phase 2 — Generator nodes // --------------------------------------------------------------------------- +class Bricks2Node : public TextureNode { +public: + void init() override; +}; + class DirectionalScratchesNode : public TextureNode { public: void init() override; diff --git a/src/texturelab/libraries/v3/bricks2.cpp b/src/texturelab/libraries/v3/bricks2.cpp new file mode 100644 index 00000000..a90084ec --- /dev/null +++ b/src/texturelab/libraries/v3/bricks2.cpp @@ -0,0 +1,264 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void Bricks2Node::init() +{ + this->title = "Bricks 2"; + + this->addInput("mortarMap"); + this->addInput("bevelMap"); + + this->addEnumProp("pattern", "Pattern", + {"Running Bond", "Stack Bond", "Herringbone", + "Basket Weave"}); + + this->addIntProp("rows", "Rows", 6, 1, 20, 1); + this->addIntProp("columns", "Columns", 6, 1, 20, 1); + this->addFloatProp("brickAspect", "Brick Aspect Ratio", 2.0, 0.5, 4.0, 0.1); + this->addFloatProp("offset", "Offset", 0.5, 0, 1, 0.1); + + // mortar + auto mortarProps = this->createGroup("Mortar"); + mortarProps->collapsed = false; + mortarProps->add( + this->addFloatProp("mortarWidth", "Mortar Width", 0.08, 0, 0.5, 0.01)); + mortarProps->add(this->addFloatProp("mortarMapStrength", + "Mortar Map Strength", 0.5, 0, 1, + 0.01)); + + // shape + auto shapeProps = this->createGroup("Shape"); + shapeProps->collapsed = false; + shapeProps->add(this->addFloatProp("shapeVariance", "Shape Variance", 0.0, + 0, 1, 0.01)); + shapeProps->add( + this->addFloatProp("roundness", "Roundness", 0.0, 0, 1, 0.01)); + + // bevel + auto bevelProps = this->createGroup("Bevel"); + bevelProps->collapsed = false; + bevelProps->add( + this->addFloatProp("bevelAmount", "Bevel Amount", 0.0, 0, 1, 0.01)); + bevelProps->add(this->addFloatProp("bevelMapStrength", + "Bevel Map Strength", 1.0, 0, 1, 0.01)); + + // height + auto heightProps = this->createGroup("Height"); + heightProps->collapsed = false; + heightProps->add( + this->addFloatProp("heightMin", "Height Min", 0.0, 0, 1, 0.05)); + heightProps->add( + this->addFloatProp("heightMax", "Height Max", 1.0, 0, 1, 0.05)); + heightProps->add( + this->addFloatProp("heightBalance", "Height Balance", 1.0, 0, 1, 0.05)); + heightProps->add( + this->addFloatProp("heightVariance", "Height Variance", 0, 0, 1, 0.05)); + + auto source = R""""( + // ===================== height variation ===================== + float calculateHeight(vec2 brickId) + { + float heightMin = prop_heightMin; + float heightMax = prop_heightMax; + float heightBalance = prop_heightBalance; + float heightVariance = prop_heightVariance; + + float balRand = _rand(vec2(_seed) + brickId * vec2(0.01)); + if (balRand > heightBalance) { + return 1.0; + } + + float randVariance = + _rand(vec2(_seed) + (brickId + vec2(1)) * vec2(0.01)); + randVariance *= heightVariance; + + float range = (heightMax - heightMin); + float height = heightMax - range * randVariance; + + return height; + } + + // ===================== brick cell solver ===================== + // localUV: position within the brick's bounding cell (0..1) + // brickId: stable per-brick id used for hashing + // brickDim: relative (width,height) proportions of the brick, + // used to give roundness/bevel the correct aspect ratio + struct CellInfo { + vec2 localUV; + vec2 brickId; + vec2 brickDim; + }; + + // Running Bond (staggered rows) and Stack Bond (no stagger) + CellInfo solveOrthoBond(vec2 uv, bool stagger) + { + vec2 tileSize = vec2(prop_columns, prop_rows); + vec2 pos = uv * tileSize; + + if (stagger) { + float xOffset = 0.0; + if (fract(pos.y * 0.5) > 0.5) { + xOffset = prop_offset; + } + pos.x += xOffset; + } + + vec2 brickId = floor(pos); + + // wrap around x so the hash matches the brick this one + // continues into on the opposite edge + if (brickId.x > tileSize.x - 1.0) + brickId.x = 0.0; + + CellInfo c; + c.localUV = fract(pos); + c.brickId = brickId; + c.brickDim = vec2(prop_brickAspect, 1.0); + return c; + } + + // Herringbone and Basket Weave share an LxL "weave" grid where L is + // the (rounded) brick aspect ratio. Each weave cell is either filled + // with L stacked horizontal bricks or L side-by-side vertical + // bricks, alternating in a checkerboard. Herringbone additionally + // staggers each row/column by its own index, producing the + // characteristic zig-zag. + CellInfo solveWeave(vec2 uv, bool herringbone) + { + vec2 tileSize = vec2(prop_columns, prop_rows); + vec2 pos = uv * tileSize; + + float L = max(1.0, floor(prop_brickAspect + 0.5)); + + vec2 weaveId = floor(pos / L); + vec2 q = pos - weaveId * L; + + bool vertical = mod(weaveId.x + weaveId.y, 2.0) > 0.5; + float stagger = herringbone ? 1.0 : 0.0; + + CellInfo c; + + if (!vertical) { + // L horizontal (L x 1) bricks stacked vertically + float row = floor(q.y); + float shiftedX = mod(pos.x + row * stagger, L); + + c.localUV = vec2(shiftedX / L, fract(pos.y)); + c.brickId = + vec2(floor((pos.x + row * stagger) / L), floor(pos.y)); + c.brickDim = vec2(L, 1.0); + } + else { + // L vertical (1 x L) bricks side by side + float col = floor(q.x); + float shiftedY = mod(pos.y + col * stagger, L); + + c.localUV = vec2(fract(pos.x), shiftedY / L); + c.brickId = + vec2(floor(pos.x), floor((pos.y + col * stagger) / L)); + c.brickDim = vec2(1.0, L); + } + + return c; + } + + // ===================== shape ===================== + // per-edge jitter amounts in [-1,1]: x=left, y=right, z=bottom, w=top + vec4 brickEdgeJitter(vec2 brickId) + { + vec4 j; + j.x = _rand(vec2(_seed) + brickId * vec2(0.0123) + vec2(11.0, 3.0)); + j.y = _rand(vec2(_seed) + brickId * vec2(0.0123) + vec2(23.0, 7.0)); + j.z = + _rand(vec2(_seed) + brickId * vec2(0.0123) + vec2(37.0, 17.0)); + j.w = + _rand(vec2(_seed) + brickId * vec2(0.0123) + vec2(51.0, 29.0)); + return j * 2.0 - 1.0; + } + + // rounded box SDF (centered at origin, half-extents b, corner radius r) + float sdRoundBox(vec2 p, vec2 b, float r) + { + vec2 q = abs(p) - b + r; + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r; + } + + vec4 process(vec2 uv) + { + CellInfo c; + if (prop_pattern == 0) + c = solveOrthoBond(uv, true); // Running Bond + else if (prop_pattern == 1) + c = solveOrthoBond(uv, false); // Stack Bond + else if (prop_pattern == 2) + c = solveWeave(uv, true); // Herringbone + else + c = solveWeave(uv, false); // Basket Weave + + // mortar width: uniform + optional per-pixel map + float mortarW = prop_mortarWidth; + if (mortarMap_connected) { + float m = texture(mortarMap, uv).r; + mortarW += (m - 0.5) * prop_mortarMapStrength; + } + mortarW = clamp(mortarW, 0.0, 0.45); + + // non-uniform brick shapes: jitter each edge independently, + // capped so edges never cross into the mortar of the + // neighboring brick + vec4 jitter = brickEdgeJitter(c.brickId); + float jitterAmount = prop_shapeVariance * mortarW * 0.9; + + float left = mortarW + jitter.x * jitterAmount; + float right = mortarW + jitter.y * jitterAmount; + float bottom = mortarW + jitter.z * jitterAmount; + float top = mortarW + jitter.w * jitterAmount; + + vec2 boxMin = vec2(left, bottom); + vec2 boxMax = vec2(1.0 - right, 1.0 - top); + vec2 center = (boxMin + boxMax) * 0.5; + vec2 halfExtent = (boxMax - boxMin) * 0.5; + + // scale into the brick's own proportions so roundness/bevel + // are relative to the brick shape, not the grid cell + vec2 p = (c.localUV - center) * c.brickDim; + vec2 b = halfExtent * c.brickDim; + + float shortSide = 2.0 * min(b.x, b.y); + + float radius = clamp(prop_roundness, 0.0, 1.0) * 0.5 * shortSide; + radius = min(radius, min(b.x, b.y)); + + float sdf = sdRoundBox(p, b, radius); + + float mask = sdf <= 0.0 ? 1.0 : 0.0; + + // bevel: darken a band along the inside of each brick edge + float bevelAmt = prop_bevelAmount; + if (bevelMap_connected) { + float bm = texture(bevelMap, uv).r; + bevelAmt *= + mix(1.0, bm, clamp(prop_bevelMapStrength, 0.0, 1.0)); + } + bevelAmt = clamp(bevelAmt, 0.0, 1.0); + + float bevelWidth = bevelAmt * 0.5 * shortSide; + float bevelMix = 1.0; + if (bevelWidth > 0.0001) { + bevelMix = clamp(-sdf / bevelWidth, 0.0, 1.0); + } + + const float bevelFloor = 0.5; + float bevelMultiplier = mix(bevelFloor, 1.0, bevelMix); + + float height = calculateHeight(c.brickId); + + float finalHeight = mask * height * bevelMultiplier; + + return vec4(vec3(finalHeight), 1.0); + } + )""""; + + this->setShaderSource(source); +} From cefc88bf6414bd4cd31d9ee9a5d2bf52ff65ad56 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Mon, 15 Jun 2026 11:15:46 -0500 Subject: [PATCH 090/164] pin windows build --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2355f5c..41afd44f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -88,7 +88,7 @@ jobs: path: "*.AppImage" build-windows: - runs-on: windows-latest + runs-on: windows-2022 steps: - name: Checkout repository From 7899592d8e1106a62081cd2d2a6508ed20452882 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Mon, 15 Jun 2026 11:35:20 -0500 Subject: [PATCH 091/164] update slack build message --- .github/workflows/build.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 41afd44f..377b75d4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -208,16 +208,25 @@ jobs: esac } + download_line() { + local result="$1" label="$2" url="$3" + if [[ "$result" == "success" ]]; then + echo "$(status_emoji "$result") **$label** — [Download]($url)" + else + echo "$(status_emoji "$result") **$label** — build $result" + fi + } + if [[ "$LINUX_RESULT" == "success" && "$WINDOWS_RESULT" == "success" && "$MACOS_RESULT" == "success" ]]; then TITLE="Build succeeded — $BRANCH @ $SHORT_SHA" COLOR=3066993 - DESCRIPTION="[Linux]($S3_BASE/linux-$SHA.zip) · [Windows]($S3_BASE/windows-$SHA.zip) · [macOS]($S3_BASE/macos-$SHA.zip)" else TITLE="Build failed — $BRANCH @ $SHORT_SHA" COLOR=15158332 - DESCRIPTION="$(status_emoji $LINUX_RESULT) Linux · $(status_emoji $WINDOWS_RESULT) Windows · $(status_emoji $MACOS_RESULT) macOS\n[View run]($RUN_URL)" fi + DESCRIPTION="$(download_line "$LINUX_RESULT" "Linux" "$S3_BASE/linux-$SHA.zip")\n$(download_line "$WINDOWS_RESULT" "Windows" "$S3_BASE/windows-$SHA.zip")\n$(download_line "$MACOS_RESULT" "macOS" "$S3_BASE/macos-$SHA.zip")\n\n[View run]($RUN_URL)" + curl -s -X POST "$DISCORD_WEBHOOK" \ -H "Content-Type: application/json" \ -d "{ From 310d7a8346b6420b5b2424fe4d255c2342cf6201 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 20 Jun 2026 14:04:13 -0500 Subject: [PATCH 092/164] render alpha channel in 3d view --- src/texturelab/mainwindow.cpp | 6 ++++++ src/viewer3d/assets/material_info.glsl | 5 +++++ src/viewer3d/assets/textures.glsl | 15 ++++++++++++++ src/viewer3d/renderer/renderer.cpp | 7 +++++++ src/viewer3d/renderer/renderer.h | 1 + src/viewer3d/viewer3d.cpp | 27 +++++++++++++++++++++++++- src/viewer3d/viewer3d.h | 2 ++ 7 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index cb45f980..62d479b9 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -239,6 +239,9 @@ void MainWindow::passTextureChannelsToViewer3D() case TextureChannel::AO: viewer->setAoTexture(node->textureId()); break; + case TextureChannel::Alpha: + viewer->setAlphaTexture(node->textureId()); + break; default: break; } @@ -348,6 +351,9 @@ void MainWindow::setProject(TextureProjectPtr project) case TextureChannel::AO: viewer->setAoTexture(texId); break; + case TextureChannel::Alpha: + viewer->setAlphaTexture(texId); + break; default: break; } diff --git a/src/viewer3d/assets/material_info.glsl b/src/viewer3d/assets/material_info.glsl index 86a379a1..9b867326 100644 --- a/src/viewer3d/assets/material_info.glsl +++ b/src/viewer3d/assets/material_info.glsl @@ -219,6 +219,11 @@ vec4 getBaseColor() //baseColor *= baseColorMap; #endif +#ifdef HAS_ALPHA_MAP + // Separate grayscale alpha/opacity map, independent of the base color map's own alpha. + baseColor.a *= texture(u_AlphaSampler, getAlphaUV()).r; +#endif + return baseColor * getVertexColor(); } diff --git a/src/viewer3d/assets/textures.glsl b/src/viewer3d/assets/textures.glsl index 54d8b7b0..09649c0c 100644 --- a/src/viewer3d/assets/textures.glsl +++ b/src/viewer3d/assets/textures.glsl @@ -91,6 +91,10 @@ uniform sampler2D u_RoughnessSampler; uniform int u_RoughnessUVSet; uniform mat3 u_RoughnessUVTransform; +uniform sampler2D u_AlphaSampler; +uniform int u_AlphaUVSet; +uniform mat3 u_AlphaUVTransform; + vec2 getBaseColorUV() { vec3 uv = vec3(u_BaseColorUVSet < 1 ? v_texcoord_0 : v_texcoord_1, 1.0); @@ -135,6 +139,17 @@ vec2 getRoughnessUV() return uv.xy; } +vec2 getAlphaUV() +{ + vec3 uv = vec3(u_AlphaUVSet < 1 ? v_texcoord_0 : v_texcoord_1, 1.0); + +#ifdef HAS_ALPHA_UV_TRANSFORM + uv = u_AlphaUVTransform * uv; +#endif + + return uv.xy; +} + #endif diff --git a/src/viewer3d/renderer/renderer.cpp b/src/viewer3d/renderer/renderer.cpp index 6784d804..523e14ec 100644 --- a/src/viewer3d/renderer/renderer.cpp +++ b/src/viewer3d/renderer/renderer.cpp @@ -93,6 +93,8 @@ void Renderer::updateMaterial(Material* material) flags << "HAS_HEIGHT_MAP 1"; if (material->aoMapId != 0) flags << "HAS_OCCLUSION_MAP 1"; + if (material->alphaMapId != 0) + flags << "HAS_ALPHA_MAP 1"; // flags << "HAS_NORMAL_MAP 1"; // flags << "HAS_ROUGHNESS_MAP 1"; // flags << "HAS_METALNESS_MAP 1"; @@ -333,6 +335,11 @@ void Renderer::renderGltfMesh(Mesh* mesh, Material* material, shader->setUniformValue("u_OcclusionUVSet", 0); shader->setUniformValue("u_OcclusionStrength", 1.0f); + shader->setUniformValue("u_AlphaSampler", 6); + gl->glActiveTexture(GL_TEXTURE6); + bindLinear(mat->alphaMapId); + shader->setUniformValue("u_AlphaUVSet", 0); + // albedo // mainProgram->setUniformValue("u_BaseColorFactor", mat->albedo); // shader->setUniformValue("u_BaseColorSampler", 0); diff --git a/src/viewer3d/renderer/renderer.h b/src/viewer3d/renderer/renderer.h index 735b9ea3..ebb5b68d 100644 --- a/src/viewer3d/renderer/renderer.h +++ b/src/viewer3d/renderer/renderer.h @@ -65,6 +65,7 @@ struct Material { GLuint roughnessMapId = 0; GLuint heightMapId = 0; GLuint aoMapId = 0; + GLuint alphaMapId = 0; // QOpenGLTexture* albedoMap = nullptr; // QOpenGLTexture* normalMap = nullptr; diff --git a/src/viewer3d/viewer3d.cpp b/src/viewer3d/viewer3d.cpp index 79d9097c..804cb287 100644 --- a/src/viewer3d/viewer3d.cpp +++ b/src/viewer3d/viewer3d.cpp @@ -142,11 +142,21 @@ void Viewer3D::paintGL() gl->glDepthFunc(GL_LESS); } - // render gltf mesh + // render gltf mesh, double-sided: draw the back faces first and the + // front faces second so alpha-blended fragments composite back-to-front gl->glEnable(GL_BLEND); gl->glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + gl->glEnable(GL_CULL_FACE); + + gl->glCullFace(GL_FRONT); + renderer->renderGltfMesh(gltfMesh, material, camPos, worldMatrix, + viewMatrix, projMatrix); + + gl->glCullFace(GL_BACK); renderer->renderGltfMesh(gltfMesh, material, camPos, worldMatrix, viewMatrix, projMatrix); + + gl->glDisable(GL_CULL_FACE); gl->glDisable(GL_BLEND); // test several in a row @@ -549,6 +559,20 @@ void Viewer3D::clearAoTexture() this->material->needsUpdate = true; } +void Viewer3D::setAlphaTexture(GLuint texId) +{ + this->material->alphaMapId = texId; + this->material->needsUpdate = true; +} + +void Viewer3D::clearAlphaTexture() +{ + if (!this->material) + return; + this->material->alphaMapId = 0; + this->material->needsUpdate = true; +} + void Viewer3D::clearTextures() { this->clearAlbedoTexture(); @@ -557,6 +581,7 @@ void Viewer3D::clearTextures() this->clearRoughnessTexture(); this->clearHeightTexture(); this->clearAoTexture(); + this->clearAlphaTexture(); } void Viewer3D::resetCamera() diff --git a/src/viewer3d/viewer3d.h b/src/viewer3d/viewer3d.h index 4c0afee1..8145034d 100644 --- a/src/viewer3d/viewer3d.h +++ b/src/viewer3d/viewer3d.h @@ -108,6 +108,8 @@ class Viewer3D : public QOpenGLWidget { void setHeightScale(float scale); void setAoTexture(GLuint texId); void clearAoTexture(); + void setAlphaTexture(GLuint texId); + void clearAlphaTexture(); void resetMaterial(); void clearTextures(); From 373520b6a1bef7674e390f124ca72de15ad4552a Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 20 Jun 2026 14:46:58 -0500 Subject: [PATCH 093/164] add Set Alpha node --- src/texturelab/CMakeLists.txt | 1 + src/texturelab/libraries/library.cpp | 1 + src/texturelab/libraries/libv3.h | 5 +++ src/texturelab/libraries/v3/setalpha.cpp | 51 ++++++++++++++++++++++++ 4 files changed, 58 insertions(+) create mode 100644 src/texturelab/libraries/v3/setalpha.cpp diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index f0d81772..4193b1af 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -120,6 +120,7 @@ set(LIBRARYV3 ./libraries/v3/emboss.cpp ./libraries/v3/vibrance.cpp ./libraries/v3/colortomask.cpp + ./libraries/v3/setalpha.cpp ./libraries/v3/toongradient.cpp ./libraries/v3/autolevels.cpp # Phase 2 — Generators diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index aadd312f..b07bdfd9 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -245,6 +245,7 @@ Library* createLibraryV3() lib->addNode("vibrance", "Vibrance", ":nodes/hsl.png"); lib->addNode("colortomask", "Color To Mask", ":nodes/extractchannel.png"); + lib->addNode("setalpha", "Set Alpha", ":nodes/rgbamerge.png"); lib->addNode("toongradient", "Toon Gradient", ":nodes/gradientmap.png"); lib->addNode("autolevels", "Auto Levels", diff --git a/src/texturelab/libraries/libv3.h b/src/texturelab/libraries/libv3.h index 89af8d49..e77b269f 100644 --- a/src/texturelab/libraries/libv3.h +++ b/src/texturelab/libraries/libv3.h @@ -33,6 +33,11 @@ class ColorToMaskNode : public TextureNode { void init() override; }; +class SetAlphaNode : public TextureNode { +public: + void init() override; +}; + class ToonGradientNode : public TextureNode { public: void init() override; diff --git a/src/texturelab/libraries/v3/setalpha.cpp b/src/texturelab/libraries/v3/setalpha.cpp new file mode 100644 index 00000000..7c6a3d4c --- /dev/null +++ b/src/texturelab/libraries/v3/setalpha.cpp @@ -0,0 +1,51 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Quick utility to attach an alpha channel to a texture: RGB comes from +// `rgba`, alpha comes from a chosen channel of `alpha`. Saves building a +// 4-input RGBA Merge graph just to swap one channel. +void SetAlphaNode::init() +{ + this->title = "Set Alpha"; + + this->addInput("rgba"); + this->addInput("alpha"); + + auto prop = this->addEnumProp( + "alphaChannel", "Alpha Channel", + {"Red", "Green", "Blue", "Alpha", "Average (RGB)"}); + prop->setValue(0); + + this->addBoolProp("invert", "Invert Alpha", false); + + auto source = R""""( + float getChannel(vec4 inputData, int mode) + { + if (mode == 0) return inputData.r; + if (mode == 1) return inputData.g; + if (mode == 2) return inputData.b; + if (mode == 3) return inputData.a; + if (mode == 4) { + return (inputData.r + inputData.g + inputData.b) * 0.3333333; + } + + return 0.0; + } + + vec4 process(vec2 uv) + { + vec3 rgb = rgba_connected ? texture(rgba, uv).rgb : vec3(0.0); + + float a = 1.0; + if (alpha_connected) { + a = getChannel(texture(alpha, uv), prop_alphaChannel); + if (prop_invert) a = 1.0 - a; + } + + return vec4(rgb, a); + } + )""""; + + this->setShaderSource(source); +} From 973997f65a39881d0354f9c1cd7a7215a2dba4ce Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 20 Jun 2026 16:00:45 -0500 Subject: [PATCH 094/164] remove ok and cancel buttons from color picker --- src/colorpicker/colorpicker.cpp | 79 +++++++++++++++---- src/colorpicker/colorpicker.h | 10 +++ .../widgets/properties/propwidgets.cpp | 5 +- 3 files changed, 75 insertions(+), 19 deletions(-) diff --git a/src/colorpicker/colorpicker.cpp b/src/colorpicker/colorpicker.cpp index c12afe18..14dada89 100644 --- a/src/colorpicker/colorpicker.cpp +++ b/src/colorpicker/colorpicker.cpp @@ -1,17 +1,26 @@ #include "colorpicker.h" #include "./widgets.h" -#include +#include #include #include #include +#include #include #include -#include #include #include ColorPicker::ColorPicker() { + // Frameless tool window instead of Qt::Popup: Qt::Popup does an X11 + // keyboard/pointer grab to detect outside clicks, which also blocks + // global WM shortcuts (e.g. PrintScreen) while it's open. Outside + // clicks are instead detected manually via the app-wide event filter + // below, which doesn't require any grab. + setWindowFlags(Qt::Tool | Qt::FramelessWindowHint + | Qt::WindowStaysOnTopHint); + qApp->installEventFilter(this); + svBox = new SVBox(); hueSlider = new HueSlider(); // alphaSlider = new AlphaSlider(); @@ -38,23 +47,10 @@ ColorPicker::ColorPicker() vlayout->addWidget(hueSlider); // vlayout->addWidget(alphaSlider); - // add OK and Cancel buttons - auto buttonBox = - new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); - connect(buttonBox, &QDialogButtonBox::rejected, this, [this]() { - // Revert to original color on cancel - svBox->setColor(originalColor); - hueSlider->setColor(originalColor); - emit onColorChanged(originalColor); - QDialog::reject(); - }); - vlayout->addWidget(buttonBox); - this->setLayout(vlayout); // this->setBaseSize(400, 500); - this->resize(400, 330); + this->resize(400, 300); } void ColorPicker::setColor(const QColor& color) @@ -63,4 +59,53 @@ void ColorPicker::setColor(const QColor& color) svBox->setColor(color); hueSlider->setColor(color); // alphaSlider->setColor(color); -} \ No newline at end of file +} + +void ColorPicker::cancel() +{ + // revert to the color the dialog was opened with + svBox->setColor(originalColor); + hueSlider->setColor(originalColor); + emit onColorChanged(originalColor); + reject(); +} + +void ColorPicker::keyPressEvent(QKeyEvent* event) +{ + if (event->key() == Qt::Key_Escape) { + cancel(); + return; + } + + event->ignore(); + + // QDialog::keyPressEvent(event); +} + +void ColorPicker::hideEvent(QHideEvent* event) +{ + QDialog::hideEvent(event); + emit onClosed(); +} + +void ColorPicker::showEvent(QShowEvent* event) +{ + QDialog::showEvent(event); + // Tool windows aren't always given keyboard focus by the window + // manager on their own, unlike Qt::Popup; claim it explicitly so + // Escape reaches us. + raise(); + activateWindow(); +} + +bool ColorPicker::eventFilter(QObject* watched, QEvent* event) +{ + if (event->type() == QEvent::MouseButtonPress) { + auto widget = qobject_cast(watched); + if (widget && widget != this && !this->isAncestorOf(widget)) { + close(); + } + } + + return QDialog::eventFilter(watched, event); +} diff --git a/src/colorpicker/colorpicker.h b/src/colorpicker/colorpicker.h index e1829d4c..fcb6e26a 100644 --- a/src/colorpicker/colorpicker.h +++ b/src/colorpicker/colorpicker.h @@ -4,6 +4,9 @@ class SVBox; class HueSlider; class AlphaSlider; +class QKeyEvent; +class QHideEvent; +class QShowEvent; class ColorPicker : public QDialog { Q_OBJECT @@ -16,10 +19,17 @@ class ColorPicker : public QDialog { void onColorChanged(const QColor& color); void onClosed(); +protected: + void keyPressEvent(QKeyEvent* event) override; + void hideEvent(QHideEvent* event) override; + void showEvent(QShowEvent* event) override; + bool eventFilter(QObject* watched, QEvent* event) override; + private: void initUI(); void colorChangedByEditor(QColor color); void colorChangedByUI(QColor color); + void cancel(); SVBox* svBox; HueSlider* hueSlider; diff --git a/src/texturelab/widgets/properties/propwidgets.cpp b/src/texturelab/widgets/properties/propwidgets.cpp index 74d9932f..c1d92222 100644 --- a/src/texturelab/widgets/properties/propwidgets.cpp +++ b/src/texturelab/widgets/properties/propwidgets.cpp @@ -394,9 +394,10 @@ bool ColorPropWidget::eventFilter(QObject* obj, QEvent* event) emit valueChanged(color); // signal value changed } }); + connect(picker, &ColorPicker::onClosed, picker, + &ColorPicker::deleteLater); - picker->exec(); - delete picker; + picker->show(); return true; } From 73231521cd14712428b00fd294ed0c10710b74dc Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 20 Jun 2026 17:40:20 -0500 Subject: [PATCH 095/164] add library migration system --- src/texturelab/CMakeLists.txt | 4 + .../libraries/libraryversionmigrator.cpp | 133 ++++++++++++++++++ .../libraries/libraryversionmigrator.h | 61 ++++++++ src/texturelab/libraries/libversion.cpp | 49 +++++++ src/texturelab/libraries/libversion.h | 27 ++++ src/texturelab/mainwindow.cpp | 59 +++++++- src/texturelab/mainwindow.h | 4 + src/texturelab/models.h | 1 + src/texturelab/project.cpp | 25 +++- src/texturelab/project.h | 7 + src/texturelab/widgets/librarywidget.cpp | 34 +++++ src/texturelab/widgets/librarywidget.h | 13 ++ 12 files changed, 410 insertions(+), 7 deletions(-) create mode 100644 src/texturelab/libraries/libraryversionmigrator.cpp create mode 100644 src/texturelab/libraries/libraryversionmigrator.h create mode 100644 src/texturelab/libraries/libversion.cpp create mode 100644 src/texturelab/libraries/libversion.h diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 4193b1af..c7980a19 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -165,6 +165,10 @@ set(PROJECT_SOURCES ./libraries/libv3.h ./libraries/library.h ./libraries/library.cpp + ./libraries/libversion.h + ./libraries/libversion.cpp + ./libraries/libraryversionmigrator.h + ./libraries/libraryversionmigrator.cpp ./widgets/graphwidget.h ./widgets/graphwidget.cpp ./widgets/librarywidget.h diff --git a/src/texturelab/libraries/libraryversionmigrator.cpp b/src/texturelab/libraries/libraryversionmigrator.cpp new file mode 100644 index 00000000..1da7aa33 --- /dev/null +++ b/src/texturelab/libraries/libraryversionmigrator.cpp @@ -0,0 +1,133 @@ +#include "libraryversionmigrator.h" + +namespace { + +// v2 -> v3. The only non-empty step table that exists today. Every other +// v2 typeName resolves to the same class in v3 and needs no entry here — +// only the typeNames that were actually removed, renamed, or silently +// swapped for a different implementation are listed. +// +// No propertyKeyRenames are needed for any of these: the existing +// node-load loop (Project::loadTextureFromJson) already applies JSON +// properties by key and skips anything the target node doesn't have, +// which reproduces "copy matching props, drop the rest, default the new +// ones" without any extra code here. +const QVector& v2ToV3Table() +{ + static const QVector table = { + {"floodfill", "floodfillv2", {}}, + {"floodfillsampler", "floodfillv2sampler", {}}, + {"floodfilltobbox", "floodfillv2tobbox", {}}, + {"floodfilltocolor", "floodfillv2tocolor", {}}, + {"floodfilltogradient", "floodfillv2togradient", {}}, + {"floodfilltorandomcolor", "floodfillv2torandomcolor", {}}, + {"floodfilltorandomintensity", "floodfillv2torandomintensity", {}}, + {"bevel", "bevelv2", {}}, + {"perlin3d", "perlinnoise3d", {}}, + {"blend", "blend", {}}, // same name, new class (BlendV3Node) + {"cell", "cell", {}}, // same name, new class (CellV3Node) + {"linecell", "linecell", {}}, // same name, new class (LineCellV3Node) + {"solidcell", "solidcell", {}}, // same name, new class (SolidCellV3Node) + }; + return table; +} + +} // namespace + +QVector migrationStepTable(LibVersion from) +{ + switch (from) { + case LibVersion::V1: + return {}; // v1 -> v2 is purely additive, nothing to migrate + case LibVersion::V2: + return v2ToV3Table(); + case LibVersion::V3: + return {}; // current version: no further step + } + return {}; +} + +LibraryVersionMigrator::LibraryVersionMigrator(QJsonObject projectJson) + : _json(std::move(projectJson)), _target(currentLibVersion()) +{ + auto versionStr = _json["libraryVersion"].toString(); + if (!versionStr.isEmpty()) + _source = libVersionFromString(versionStr); + else + _source = looksLegacy(_json) ? LibVersion::V2 : currentLibVersion(); +} + +bool LibraryVersionMigrator::needsMigration() const +{ + return _source != _target; +} + +QList LibraryVersionMigrator::versionsCrossed() const +{ + QList chain; + for (LibVersion v = _source; v != _target; v = nextLibVersion(v)) + chain.append(nextLibVersion(v)); + return chain; +} + +QJsonObject LibraryVersionMigrator::migrate() const +{ + QJsonObject result = _json; + QJsonArray nodes = result["nodes"].toArray(); + + for (LibVersion v = _source; v != _target; v = nextLibVersion(v)) + nodes = applyStep(nodes, migrationStepTable(v)); + + result["nodes"] = nodes; + result["libraryVersion"] = libVersionToString(_target); + return result; +} + +QJsonArray LibraryVersionMigrator::applyStep( + const QJsonArray& nodes, const QVector& table) +{ + if (table.isEmpty()) + return nodes; + + QJsonArray result; + for (const auto& item : nodes) { + auto nodeObj = item.toObject(); + auto typeName = nodeObj["typeName"].toString(); + + for (const auto& entry : table) { + if (entry.oldTypeName != typeName) + continue; + + nodeObj["typeName"] = entry.newTypeName; + + if (!entry.propertyKeyRenames.isEmpty()) { + auto props = nodeObj["properties"].toObject(); + for (auto it = entry.propertyKeyRenames.begin(); + it != entry.propertyKeyRenames.end(); ++it) { + if (props.contains(it.key())) { + props[it.value()] = props[it.key()]; + props.remove(it.key()); + } + } + nodeObj["properties"] = props; + } + break; + } + + result.append(nodeObj); + } + return result; +} + +bool LibraryVersionMigrator::looksLegacy(const QJsonObject& json) +{ + auto nodes = json["nodes"].toArray(); + for (const auto& item : nodes) { + auto typeName = item.toObject()["typeName"].toString(); + for (const auto& entry : v2ToV3Table()) { + if (entry.oldTypeName == typeName) + return true; + } + } + return false; +} diff --git a/src/texturelab/libraries/libraryversionmigrator.h b/src/texturelab/libraries/libraryversionmigrator.h new file mode 100644 index 00000000..f8844a0a --- /dev/null +++ b/src/texturelab/libraries/libraryversionmigrator.h @@ -0,0 +1,61 @@ +#pragma once + +#include "libversion.h" +#include +#include +#include +#include +#include +#include + +// One node's identity/shape change for a single version step (from -> next). +struct NodeTypeMigration { + QString oldTypeName; + QString newTypeName; + + // Rare: a property key that was renamed on an otherwise-equivalent + // node. Old key -> new key. Empty for every step table that exists + // today (see libraryversionmigrator.cpp). + QMap propertyKeyRenames; +}; + +// Returns the migration table to apply when stepping from `from` to +// nextLibVersion(from). An empty vector means that step has no typeName +// changes at all (e.g. v1 -> v2 today). +QVector migrationStepTable(LibVersion from); + +// Migrates a project's raw JSON from whatever library version it declares +// up to a target version (defaults to the current one), walking one +// version step at a time. Pure data transform: only QJsonObject / +// QJsonArray / QString are touched. No Library, TextureNode, Prop, or +// OpenGL/GPU resource is created anywhere in this class. +class LibraryVersionMigrator { +public: + explicit LibraryVersionMigrator(QJsonObject projectJson); + + LibVersion sourceVersion() const { return _source; } + LibVersion targetVersion() const { return _target; } + void setTargetVersion(LibVersion version) { _target = version; } + + bool needsMigration() const; + + // The chain of versions that migrate() will step into, e.g. [V2, V3] + // when migrating a V1 file up to V3. Empty if needsMigration() is + // false. Intended for building a human-readable "v1 -> v2 -> v3" + // message. + QList versionsCrossed() const; + + // Returns the migrated project JSON. Does not mutate the JSON passed + // to the constructor. Idempotent. Sets "libraryVersion" on the result + // to libVersionToString(targetVersion()). + QJsonObject migrate() const; + +private: + QJsonObject _json; + LibVersion _source; + LibVersion _target; + + static QJsonArray applyStep(const QJsonArray& nodes, + const QVector& table); + static bool looksLegacy(const QJsonObject& json); +}; diff --git a/src/texturelab/libraries/libversion.cpp b/src/texturelab/libraries/libversion.cpp new file mode 100644 index 00000000..a67a2b0b --- /dev/null +++ b/src/texturelab/libraries/libversion.cpp @@ -0,0 +1,49 @@ +#include "libversion.h" +#include "library.h" + +LibVersion currentLibVersion() { return kCurrentLibVersion; } + +LibVersion libVersionFromString(const QString& str) +{ + auto s = str.trimmed().toLower(); + if (s == "v1") + return LibVersion::V1; + if (s == "v2") + return LibVersion::V2; + if (s == "v3") + return LibVersion::V3; + + return currentLibVersion(); +} + +QString libVersionToString(LibVersion version) +{ + switch (version) { + case LibVersion::V1: return "v1"; + case LibVersion::V2: return "v2"; + case LibVersion::V3: return "v3"; + } + return "v3"; +} + +bool hasNextLibVersion(LibVersion version) +{ + return version != currentLibVersion(); +} + +LibVersion nextLibVersion(LibVersion version) +{ + return static_cast(static_cast(version) + 1); +} + +Library* createLibraryForVersion(LibVersion version) +{ + switch (version) { + case LibVersion::V1: + case LibVersion::V2: + return createLibraryV2(); + case LibVersion::V3: + return createLibraryV3(); + } + return createLibraryV3(); +} diff --git a/src/texturelab/libraries/libversion.h b/src/texturelab/libraries/libversion.h new file mode 100644 index 00000000..7410a36e --- /dev/null +++ b/src/texturelab/libraries/libversion.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +class Library; + +// Contiguous, ordered library versions. Adding a new version means: add the +// enum value here, move kCurrentLibVersion, extend createLibraryForVersion(), +// and add a migration step table in libraryversionmigrator.cpp. +enum class LibVersion { V1 = 0, V2 = 1, V3 = 2 }; + +constexpr LibVersion kCurrentLibVersion = LibVersion::V3; + +LibVersion currentLibVersion(); + +// Parses "v1"/"v2"/"v3" (case-insensitive). Unrecognized/empty strings fall +// back to currentLibVersion(). +LibVersion libVersionFromString(const QString& str); +QString libVersionToString(LibVersion version); + +bool hasNextLibVersion(LibVersion version); +LibVersion nextLibVersion(LibVersion version); + +// Returns the node library that should be used to load/edit a project at +// the given version. V1 and V2 currently share createLibraryV2() since the +// v1->v2 step never removed or renamed anything. +Library* createLibraryForVersion(LibVersion version); diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 62d479b9..fee4d7e8 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include #include @@ -39,6 +41,8 @@ #include "viewer3d.h" #include "models.h" +#include "libraries/libraryversionmigrator.h" +#include "libraries/libversion.h" #include "project.h" #include "props.h" @@ -300,6 +304,9 @@ void MainWindow::setProject(TextureProjectPtr project) this->graphWidget->setTextureProject(project); this->syncChannelLabelsToScene(); this->libraryWidget->setLibrary(project->library); + this->libraryWidget->setLibraryVersion( + project->libraryVersion, + project->libraryVersion == libVersionToString(currentLibVersion())); this->propWidget->clearSelection(); this->propWidget->setProject(project); @@ -520,6 +527,8 @@ void MainWindow::setupDocks() this->propWidget, graphArea); this->libraryWidget = new LibraryWidget(); + connect(this->libraryWidget, &LibraryWidget::upgradeRequested, this, + &MainWindow::upgradeCurrentProjectLibrary); setWidgetRatiosInArea(graphArea, {1.0f / 5, 3.0f / 5, 1.0f / 5}); addDock("3D View", ads::BottomDockWidgetArea, this->view3DWidget, leftArea); @@ -558,7 +567,33 @@ void MainWindow::openProject() if (filePath.isNull() || filePath.isEmpty()) return; - auto project = Project::loadTexture(filePath); + QFile file(filePath); + file.open(QIODevice::ReadOnly); + auto json = QJsonDocument::fromJson(file.readAll()).object(); + file.close(); + + LibraryVersionMigrator migrator(json); + if (migrator.needsMigration()) { + QStringList chain; + chain << libVersionToString(migrator.sourceVersion()); + for (auto v : migrator.versionsCrossed()) + chain << libVersionToString(v); + + auto choice = QMessageBox::question( + this, "Upgrade Texture?", + QString("This texture was created with library version %1.\n\n" + "Upgrade it to %2 (%3) to use the latest nodes and " + "improvements? A few node behaviors may change slightly.") + .arg(libVersionToString(migrator.sourceVersion()), + libVersionToString(migrator.targetVersion()), + chain.join(" → ")), + QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + + if (choice == QMessageBox::Yes) + json = migrator.migrate(); + } + + auto project = Project::loadTextureFromJson(json); QFileInfo fileInfo(filePath); project->name = fileInfo.baseName(); @@ -568,6 +603,28 @@ void MainWindow::openProject() addToRecentFiles(filePath); } +void MainWindow::upgradeCurrentProjectLibrary() +{ + if (!this->project) + return; + + // Round-trip through the same JSON the file format uses, so the live + // "Upgrade" button in the Library dock goes through the exact same + // pure-JSON migration path as opening a legacy file does. + auto bytes = Project::saveTexture(this->project); + auto json = QJsonDocument::fromJson(bytes).object(); + + LibraryVersionMigrator migrator(json); + if (!migrator.needsMigration()) + return; + + auto newProject = Project::loadTextureFromJson(migrator.migrate()); + newProject->name = this->project->name; + newProject->filePath = this->project->filePath; + + setProject(newProject); +} + void MainWindow::newProject() { if (!promptSaveIfDirty()) diff --git a/src/texturelab/mainwindow.h b/src/texturelab/mainwindow.h index b3e01452..673d4970 100644 --- a/src/texturelab/mainwindow.h +++ b/src/texturelab/mainwindow.h @@ -51,6 +51,10 @@ class MainWindow : public QMainWindow { void setProject(TextureProjectPtr project); + // Upgrades the currently open project's library in place, via the + // Library dock panel's "Upgrade" button. + void upgradeCurrentProjectLibrary(); + void addToRecentFiles(const QString& filePath); void updateRecentFilesMenu(); diff --git a/src/texturelab/models.h b/src/texturelab/models.h index 8297c742..9ccd6f42 100644 --- a/src/texturelab/models.h +++ b/src/texturelab/models.h @@ -72,6 +72,7 @@ class TextureProject : public QEnableSharedFromThis { QMap textureChannels; Library* library = nullptr; + QString libraryVersion = "v3"; QMap nodes; QMap connections; diff --git a/src/texturelab/project.cpp b/src/texturelab/project.cpp index c32630ac..27c7230f 100644 --- a/src/texturelab/project.cpp +++ b/src/texturelab/project.cpp @@ -1,5 +1,7 @@ #include "project.h" #include "libraries/library.h" +#include "libraries/libraryversionmigrator.h" +#include "libraries/libversion.h" #include "props.h" #include #include @@ -12,7 +14,7 @@ TextureProjectPtr Project::loadTexture(QString path) QFile file(path); file.open(QIODevice::ReadOnly); QJsonParseError error; - auto json = QJsonDocument::fromJson(file.readAll(), &error); + auto doc = QJsonDocument::fromJson(file.readAll(), &error); file.close(); if (error.error) { @@ -20,13 +22,21 @@ TextureProjectPtr Project::loadTexture(QString path) return TextureProjectPtr(nullptr); } - TextureProjectPtr texture(new TextureProject()); + return Project::loadTextureFromJson(doc.object()); +} - // qDebug() << json["libraryVersion"].toString(); +TextureProjectPtr Project::loadTextureFromJson(QJsonObject json) +{ + TextureProjectPtr texture(new TextureProject()); - // create library from version - // Library *lib = new LibraryV1(); - Library* lib = createLibraryV3(); + // Pick the library matching this JSON's own version, so legacy + // typeNames (e.g. "floodfill", "bevel", "perlin3d") that no longer + // exist in the current library still resolve instead of crashing. + // Callers that want the file upgraded should run it through + // LibraryVersionMigrator first and pass in the migrated JSON. + LibVersion version = LibraryVersionMigrator(json).sourceVersion(); + Library* lib = createLibraryForVersion(version); + texture->libraryVersion = libVersionToString(version); // scene objects auto sceneObj = json["scene"].toObject(); @@ -228,6 +238,9 @@ QByteArray Project::saveTexture(TextureProjectPtr texture) } json["connections"] = conArray; + // library version this project's nodes/properties were saved against + json["libraryVersion"] = texture->libraryVersion; + // export settings QJsonObject exportObj; exportObj["filePattern"] = texture->exportFilePattern; diff --git a/src/texturelab/project.h b/src/texturelab/project.h index bd5ddcda..9c21ca1d 100644 --- a/src/texturelab/project.h +++ b/src/texturelab/project.h @@ -1,10 +1,17 @@ #pragma once #include "models.h" +#include class Project { public: static TextureProjectPtr loadTexture(QString path); + + // Builds a project from already-parsed (and possibly migrated) JSON. + // Used directly by MainWindow when it has run the JSON through + // LibraryVersionMigrator before constructing any node/library objects. + static TextureProjectPtr loadTextureFromJson(QJsonObject json); + static QByteArray saveTexture(TextureProjectPtr texture); }; \ No newline at end of file diff --git a/src/texturelab/widgets/librarywidget.cpp b/src/texturelab/widgets/librarywidget.cpp index 8a86e87f..0fe6d781 100644 --- a/src/texturelab/widgets/librarywidget.cpp +++ b/src/texturelab/widgets/librarywidget.cpp @@ -3,11 +3,14 @@ #include "./libraries/library.h" #include +#include +#include #include #include #include #include #include +#include #include #include #include @@ -28,6 +31,24 @@ LibraryWidget::LibraryWidget() : QWidget() this->setMinimumWidth(100); this->setLayout(new QVBoxLayout()); + // library version indicator + upgrade button + auto versionRow = new QWidget(this); + auto versionLayout = new QHBoxLayout(versionRow); + versionLayout->setContentsMargins(0, 0, 0, 0); + + versionLabel = new QLabel(versionRow); + versionLayout->addWidget(versionLabel); + + versionLayout->addStretch(); + + upgradeButton = new QPushButton("Upgrade", versionRow); + upgradeButton->setVisible(false); + connect(upgradeButton, &QPushButton::clicked, + this, &LibraryWidget::upgradeRequested); + versionLayout->addWidget(upgradeButton); + + this->layout()->addWidget(versionRow); + // search box searchBar = new QLineEdit(this); searchBar->setPlaceholderText("search"); @@ -45,6 +66,19 @@ LibraryWidget::LibraryWidget() : QWidget() this->setLibrary(nullptr); } +void LibraryWidget::setLibraryVersion(const QString& version, bool isCurrent) +{ + if (isCurrent) { + versionLabel->setText(QString("Library: %1").arg(version)); + versionLabel->setStyleSheet(""); + } + else { + versionLabel->setText(QString("Library: %1 (outdated)").arg(version)); + versionLabel->setStyleSheet("color: orange;"); + } + upgradeButton->setVisible(!isCurrent); +} + void LibraryWidget::addSpecialItem(const QString& name, const QString& iconPath, PopupItemType type) { diff --git a/src/texturelab/widgets/librarywidget.h b/src/texturelab/widgets/librarywidget.h index a74c1199..2571ad23 100644 --- a/src/texturelab/widgets/librarywidget.h +++ b/src/texturelab/widgets/librarywidget.h @@ -8,6 +8,8 @@ class Library; class LibraryListWidget; class QLineEdit; +class QLabel; +class QPushButton; // https://stackoverflow.com/questions/37331270/how-to-create-grid-style-qlistwidget class LibraryWidget : public QWidget { @@ -16,15 +18,26 @@ class LibraryWidget : public QWidget { LibraryWidget(); void setLibrary(Library* lib); + // Shows which library version the open project is on. When + // `isCurrent` is false, an "Upgrade" button is shown that emits + // upgradeRequested(). + void setLibraryVersion(const QString& version, bool isCurrent); + LibraryListWidget* listWidget; QLineEdit* searchBar; +signals: + void upgradeRequested(); + private slots: void filterList(const QString& text); private: void addSpecialItem(const QString& name, const QString& iconPath, PopupItemType type); + + QLabel* versionLabel; + QPushButton* upgradeButton; }; class LibraryItemMimeData : public QMimeData { From be4f9c46805bf03306cdf58814942f48dca9f40f Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 20 Jun 2026 17:53:24 -0500 Subject: [PATCH 096/164] show alert dialog before upgrade --- src/texturelab/mainwindow.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index fee4d7e8..1475a24f 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -618,6 +618,18 @@ void MainWindow::upgradeCurrentProjectLibrary() if (!migrator.needsMigration()) return; + auto choice = QMessageBox::warning( + this, "Upgrade Library Version?", + "Upgrading the library version is irreversible and clears the " + "undo/redo history for this session.\n\n" + "Save your project (or save a copy) first if you want to keep the " + "ability to go back to the current version.\n\n" + "Continue with the upgrade?", + QMessageBox::Yes | QMessageBox::Cancel, QMessageBox::Cancel); + + if (choice != QMessageBox::Yes) + return; + auto newProject = Project::loadTextureFromJson(migrator.migrate()); newProject->name = this->project->name; newProject->filePath = this->project->filePath; From 64eafd7d716bf7e14feee40b0d68ce415ecc7751 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 20 Jun 2026 18:16:45 -0500 Subject: [PATCH 097/164] fix old texture checking --- src/texturelab/libraries/libraryversionmigrator.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/texturelab/libraries/libraryversionmigrator.cpp b/src/texturelab/libraries/libraryversionmigrator.cpp index 1da7aa33..2bab2616 100644 --- a/src/texturelab/libraries/libraryversionmigrator.cpp +++ b/src/texturelab/libraries/libraryversionmigrator.cpp @@ -125,6 +125,13 @@ bool LibraryVersionMigrator::looksLegacy(const QJsonObject& json) for (const auto& item : nodes) { auto typeName = item.toObject()["typeName"].toString(); for (const auto& entry : v2ToV3Table()) { + // Entries where oldTypeName == newTypeName (blend, cell, + // linecell, solidcell) only changed which class backs that + // name — the name itself is just as valid in a current-version + // file, so it can't be used as a legacy signal. Only count + // typeNames that were actually removed/renamed. + if (entry.oldTypeName == entry.newTypeName) + continue; if (entry.oldTypeName == typeName) return true; } From 304af3729361f9d777a2f17d9167effc4892edfa Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 20 Jun 2026 18:26:19 -0500 Subject: [PATCH 098/164] implement dragging to window to open file --- src/texturelab/mainwindow.cpp | 64 +++++++++++++++++++++----- src/texturelab/mainwindow.h | 9 ++++ src/texturelab/widgets/graphwidget.cpp | 19 ++++++-- 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 1475a24f..2bec92c7 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -52,6 +53,7 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { resize(1280, 720); + setAcceptDrops(true); undoStack = new QUndoStack(this); connect(undoStack, &QUndoStack::cleanChanged, this, &MainWindow::onCleanChanged); @@ -567,8 +569,20 @@ void MainWindow::openProject() if (filePath.isNull() || filePath.isEmpty()) return; + openProjectFromPath(filePath); +} + +void MainWindow::openProjectFromPath(const QString& filePath) +{ + if (!promptSaveIfDirty()) + return; + QFile file(filePath); - file.open(QIODevice::ReadOnly); + if (!file.open(QIODevice::ReadOnly)) { + QMessageBox::warning(this, "Open Texture", + "Could not open file:\n" + filePath); + return; + } auto json = QJsonDocument::fromJson(file.readAll()).object(); file.close(); @@ -603,6 +617,41 @@ void MainWindow::openProject() addToRecentFiles(filePath); } +void MainWindow::dragEnterEvent(QDragEnterEvent* event) +{ + if (!event->mimeData()->hasUrls()) { + event->ignore(); + return; + } + + for (const auto& url : event->mimeData()->urls()) { + if (url.isLocalFile() && + url.toLocalFile().endsWith(".texture", Qt::CaseInsensitive)) { + event->acceptProposedAction(); + return; + } + } + event->ignore(); +} + +void MainWindow::dropEvent(QDropEvent* event) +{ + for (const auto& url : event->mimeData()->urls()) { + if (!url.isLocalFile()) + continue; + + auto filePath = url.toLocalFile(); + if (!filePath.endsWith(".texture", Qt::CaseInsensitive)) + continue; + + event->acceptProposedAction(); + openProjectFromPath(filePath); + return; + } + + event->ignore(); +} + void MainWindow::upgradeCurrentProjectLibrary() { if (!this->project) @@ -882,17 +931,8 @@ void MainWindow::updateRecentFilesMenu() for (const QString& filePath : files) { QFileInfo info(filePath); - auto action = - recentFilesMenu->addAction(info.fileName(), [this, filePath]() { - if (!promptSaveIfDirty()) - return; - auto project = Project::loadTexture(filePath); - QFileInfo fileInfo(filePath); - project->name = fileInfo.baseName(); - project->filePath = filePath; - setProject(project); - addToRecentFiles(filePath); - }); + auto action = recentFilesMenu->addAction( + info.fileName(), [this, filePath]() { openProjectFromPath(filePath); }); action->setToolTip(filePath); } diff --git a/src/texturelab/mainwindow.h b/src/texturelab/mainwindow.h index 673d4970..cb1418bf 100644 --- a/src/texturelab/mainwindow.h +++ b/src/texturelab/mainwindow.h @@ -3,6 +3,8 @@ #include "DockManager.h" #include +#include +#include #include #include #include @@ -36,9 +38,16 @@ class MainWindow : public QMainWindow { void setupMenus(); void setupDocks(); void closeEvent(QCloseEvent* event) override; + void dragEnterEvent(QDragEnterEvent* event) override; + void dropEvent(QDropEvent* event) override; // menu callbacks void openProject(); + + // Shared by the Open dialog, recent-files menu, and drag-and-drop: + // prompts to save if dirty, reads the file, offers the + // version-upgrade dialog if needed, then loads it. + void openProjectFromPath(const QString& filePath); void newProject(); void saveProject(); void saveProjectAs(); diff --git a/src/texturelab/widgets/graphwidget.cpp b/src/texturelab/widgets/graphwidget.cpp index 7cd70f8b..c7a3e784 100644 --- a/src/texturelab/widgets/graphwidget.cpp +++ b/src/texturelab/widgets/graphwidget.cpp @@ -379,14 +379,22 @@ void GraphWidget::syncPositionsToModel() void GraphWidget::dragEnterEvent(QDragEnterEvent* evt) { - // qDebug() << "Drag enter"; - evt->acceptProposedAction(); + // Only claim drags we actually handle (nodes/frames/comments dragged + // from the Library panel). Anything else — e.g. a .texture file + // dragged in from the OS — must be left ignored so Qt forwards it up + // to MainWindow's dragEnterEvent instead of it being swallowed here. + if (evt->mimeData()->hasFormat(LIBRARY_ITEM_MIME_FORMAT)) + evt->acceptProposedAction(); + else + evt->ignore(); } void GraphWidget::dragMoveEvent(QDragMoveEvent* evt) { - // qDebug() << "drag move"; - evt->acceptProposedAction(); + if (evt->mimeData()->hasFormat(LIBRARY_ITEM_MIME_FORMAT)) + evt->acceptProposedAction(); + else + evt->ignore(); } void GraphWidget::dropEvent(QDropEvent* evt) @@ -446,6 +454,9 @@ void GraphWidget::dropEvent(QDropEvent* evt) evt->accept(); } + else { + evt->ignore(); + } } void GraphWidget::setTextureRenderer(TextureRenderer* renderer) From e458c452016bf82f554faef68c1bca1003c690cd Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 20 Jun 2026 18:39:01 -0500 Subject: [PATCH 099/164] fix image node save/load --- src/texturelab/props.h | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/texturelab/props.h b/src/texturelab/props.h index 1cf3f9c6..eb63308d 100644 --- a/src/texturelab/props.h +++ b/src/texturelab/props.h @@ -616,14 +616,15 @@ class ImageProp : public Prop { return; auto parts = stringData.split(";base64,"); - if (parts.length() == 0 || parts.length() == 1) + if (parts.length() < 2) return; - auto bytes = QByteArray::fromBase64(parts[0].toUtf8()); + auto bytes = QByteArray::fromBase64(parts[1].toUtf8()); QImage image; - image.loadFromData(QByteArray::fromBase64(stringData.toUtf8())); - this->value = value; + image.loadFromData(bytes); + this->value = image; + _textureDirty = true; } QJsonValue toJsonValue() override @@ -647,13 +648,14 @@ class ImageProp : public Prop { return; auto parts = stringData.split(";base64,"); - if (parts.length() == 0 || parts.length() == 1) + if (parts.length() < 2) return; - auto bytes = QByteArray::fromBase64(parts[0].toUtf8()); + auto bytes = QByteArray::fromBase64(parts[1].toUtf8()); QImage image; - image.loadFromData(QByteArray::fromBase64(stringData.toUtf8())); - this->value = value; + image.loadFromData(bytes); + this->value = image; + _textureDirty = true; } }; \ No newline at end of file From 398163ecd69397c518f4f5555f2b9e358eeb3392 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 20 Jun 2026 22:15:34 -0500 Subject: [PATCH 100/164] match old bevel to new one on upgrade --- .../libraries/libraryversionmigrator.cpp | 22 ++++- .../libraries/libraryversionmigrator.h | 9 ++ src/texturelab/libraries/v3/bevelv2.cpp | 84 ++++++++++++++----- 3 files changed, 90 insertions(+), 25 deletions(-) diff --git a/src/texturelab/libraries/libraryversionmigrator.cpp b/src/texturelab/libraries/libraryversionmigrator.cpp index 2bab2616..e9929786 100644 --- a/src/texturelab/libraries/libraryversionmigrator.cpp +++ b/src/texturelab/libraries/libraryversionmigrator.cpp @@ -22,7 +22,17 @@ const QVector& v2ToV3Table() {"floodfilltogradient", "floodfillv2togradient", {}}, {"floodfilltorandomcolor", "floodfillv2torandomcolor", {}}, {"floodfilltorandomintensity", "floodfillv2torandomintensity", {}}, - {"bevel", "bevelv2", {}}, + // bevelv2 added "invert" and "scaleInvariant" toggles that don't + // exist on the old bevel node. Their library defaults (false, + // true) are tuned for new nodes; a migrated node needs the + // opposite of both to reproduce the old node's look: old bevel's + // output polarity was flipped relative to bevelv2's, and old + // bevel always worked in raw pixel distance (not normalized + // against texture resolution). + {"bevel", "bevelv2", {}, QJsonObject{ + {"invert", true}, + {"scaleInvariant", false}, + }}, {"perlin3d", "perlinnoise3d", {}}, {"blend", "blend", {}}, // same name, new class (BlendV3Node) {"cell", "cell", {}}, // same name, new class (CellV3Node) @@ -100,8 +110,10 @@ QJsonArray LibraryVersionMigrator::applyStep( nodeObj["typeName"] = entry.newTypeName; - if (!entry.propertyKeyRenames.isEmpty()) { + if (!entry.propertyKeyRenames.isEmpty() || + !entry.migratedPropertyDefaults.isEmpty()) { auto props = nodeObj["properties"].toObject(); + for (auto it = entry.propertyKeyRenames.begin(); it != entry.propertyKeyRenames.end(); ++it) { if (props.contains(it.key())) { @@ -109,6 +121,12 @@ QJsonArray LibraryVersionMigrator::applyStep( props.remove(it.key()); } } + + for (auto it = entry.migratedPropertyDefaults.begin(); + it != entry.migratedPropertyDefaults.end(); ++it) { + props[it.key()] = it.value(); + } + nodeObj["properties"] = props; } break; diff --git a/src/texturelab/libraries/libraryversionmigrator.h b/src/texturelab/libraries/libraryversionmigrator.h index f8844a0a..4df3507e 100644 --- a/src/texturelab/libraries/libraryversionmigrator.h +++ b/src/texturelab/libraries/libraryversionmigrator.h @@ -17,6 +17,15 @@ struct NodeTypeMigration { // node. Old key -> new key. Empty for every step table that exists // today (see libraryversionmigrator.cpp). QMap propertyKeyRenames; + + // Values for properties that only exist on the *new* node, set to + // whatever reproduces the old node's look instead of the new node's + // normal default. E.g. bevelv2 added an "invert" toggle (new-node + // default: false) that must be `true` for a migrated bevel node to + // match the old bevel's polarity. Only applied to nodes going through + // this migration step — a freshly-added bevelv2 node still gets its + // ordinary library default. Key -> value (any QJsonValue). + QJsonObject migratedPropertyDefaults; }; // Returns the migration table to apply when stepping from `from` to diff --git a/src/texturelab/libraries/v3/bevelv2.cpp b/src/texturelab/libraries/v3/bevelv2.cpp index 9add044d..d7dd5a6b 100644 --- a/src/texturelab/libraries/v3/bevelv2.cpp +++ b/src/texturelab/libraries/v3/bevelv2.cpp @@ -15,6 +15,8 @@ struct BevelV2RenderData : public NodeRenderData { float distance = 50.0f; float threshold = 0.5f; int shape = 0; // 0=Linear, 1=Round, 2=Smooth + bool invert = false; + bool scaleInvariant = true; }; // ============================================================================ @@ -23,8 +25,7 @@ struct BevelV2RenderData : public NodeRenderData { class BevelV2Renderer : public NodeTextureRenderer { public: - void render(NodeRenderContext& ctx, - const NodeRenderData& baseData) override + void render(NodeRenderContext& ctx, const NodeRenderData& baseData) override { auto& data = static_cast(baseData); auto gl = ctx.gl; @@ -43,12 +44,12 @@ class BevelV2Renderer : public NodeTextureRenderer { } // Compile shaders (cached after first call) - GLuint seedShader = cache->getOrCompileShader( - "jfa_seed", standardVert(), seedFrag()); - GLuint jfaShader = cache->getOrCompileShader( - "jfa_step", standardVert(), jfaFrag()); - GLuint bevelShader = cache->getOrCompileShader( - "jfa_bevel", standardVert(), bevelFrag()); + GLuint seedShader = + cache->getOrCompileShader("jfa_seed", standardVert(), seedFrag()); + GLuint jfaShader = + cache->getOrCompileShader("jfa_step", standardVert(), jfaFrag()); + GLuint bevelShader = + cache->getOrCompileShader("jfa_bevel", standardVert(), bevelFrag()); // Acquire two intermediate textures for ping-pong GLuint texA = cache->acquireTexture(w, h); @@ -58,9 +59,10 @@ class BevelV2Renderer : public NodeTextureRenderer { cache->bindFboToTexture(texA); ctx.useShader(seedShader); ctx.bindTexture(seedShader, "image", ctx.inputs[0].textureId, 0); - gl->glUniform1f( - gl->glGetUniformLocation(seedShader, "u_threshold"), - data.threshold); + gl->glUniform1f(gl->glGetUniformLocation(seedShader, "u_threshold"), + data.threshold); + gl->glUniform1i(gl->glGetUniformLocation(seedShader, "u_invert"), + data.invert ? 1 : 0); ctx.drawQuad(); // --- JFA iteration (ping-pong) --- @@ -71,8 +73,8 @@ class BevelV2Renderer : public NodeTextureRenderer { cache->bindFboToTexture(texB); ctx.useShader(jfaShader); ctx.bindTexture(jfaShader, "u_input", texA, 0); - gl->glUniform1i( - gl->glGetUniformLocation(jfaShader, "u_stepSize"), stepSize); + gl->glUniform1i(gl->glGetUniformLocation(jfaShader, "u_stepSize"), + stepSize); ctx.drawQuad(); std::swap(texA, texB); @@ -83,12 +85,15 @@ class BevelV2Renderer : public NodeTextureRenderer { cache->bindFboToTexture(ctx.outputTextureId); ctx.useShader(bevelShader); ctx.bindTexture(bevelShader, "u_jfa", texA, 0); - gl->glUniform1f( - gl->glGetUniformLocation(bevelShader, "u_distance"), - data.distance); + gl->glUniform1f(gl->glGetUniformLocation(bevelShader, "u_distance"), + data.distance); + gl->glUniform1i(gl->glGetUniformLocation(bevelShader, "u_shape"), + data.shape); + gl->glUniform1i(gl->glGetUniformLocation(bevelShader, "u_invert"), + data.invert ? 1 : 0); gl->glUniform1i( - gl->glGetUniformLocation(bevelShader, "u_shape"), - data.shape); + gl->glGetUniformLocation(bevelShader, "u_scaleInvariant"), + data.scaleInvariant ? 1 : 0); ctx.drawQuad(); } @@ -108,14 +113,24 @@ class BevelV2Renderer : public NodeTextureRenderer { uniform sampler2D image; uniform vec2 _textureSize; uniform float u_threshold; + uniform int u_invert; void main() { vec2 uv = v_texCoord; float v = texture(image, uv).r; - // Black pixels (below threshold) are seeds — - // JFA spreads distance from them into white regions - if (v < u_threshold) + // Black pixels (below threshold) are seeds by default — + // JFA spreads distance from them into white regions, so + // white shapes end up raised and black background stays + // flat. `invert` swaps which side is treated as the seed + // (matching the old bevel node, whose two-sided signed + // distance field meant flipping its single "invert" had + // the effect of swapping which side the flat plateau fell + // on, on top of flipping the output polarity below). + bool isSeed = (u_invert == 1) ? (v >= u_threshold) + : (v < u_threshold); + + if (isSeed) fragColor = vec4(uv, v, 1.0); // Seed: store own UV else fragColor = vec4(-1.0, -1.0, v, 0.0); // No seed @@ -175,6 +190,8 @@ class BevelV2Renderer : public NodeTextureRenderer { uniform vec2 _textureSize; uniform float u_distance; uniform int u_shape; + uniform int u_invert; + uniform int u_scaleInvariant; #define SHAPE_LINEAR 0 #define SHAPE_ROUND 1 @@ -194,8 +211,15 @@ class BevelV2Renderer : public NodeTextureRenderer { float dist = length(uv - data.xy) * max(_textureSize.x, _textureSize.y); - float pixelDistance = u_distance - * (max(_textureSize.x, _textureSize.y) / 512.0); + // Scale-invariant: normalize against texture resolution so + // the bevel width in UV space stays consistent across + // resolutions (512 is the reference size the prop range + // was tuned against). Disabled, `distance` is read as a + // raw pixel count instead — matching the old (v1/v2) Bevel + // node, which always operated in absolute pixel terms. + float pixelDistance = (u_scaleInvariant == 1) + ? u_distance * (max(_textureSize.x, _textureSize.y) / 512.0) + : u_distance; float t = clamp(dist / pixelDistance, 0.0, 1.0); float bevel; @@ -210,6 +234,9 @@ class BevelV2Renderer : public NodeTextureRenderer { bevel = t; } + // if (u_invert == 1) + // bevel = 1.0 - bevel; + fragColor = vec4(vec3(bevel), 1.0); } )""""; @@ -227,6 +254,8 @@ void BevelV2Node::init() this->addFloatProp("distance", "Distance", 50.0, 0.0, 200.0, 0.5); this->addFloatProp("threshold", "Threshold", 0.5, 0.0, 1.0, 0.01); this->addEnumProp("shape", "Shape", {"Linear", "Round", "Smooth"}); + this->addBoolProp("invert", "Invert", false); + this->addBoolProp("scaleInvariant", "Scale Invariant", true); // Passthrough shader for initialization (not used during rendering — // custom renderer handles all passes) @@ -260,5 +289,14 @@ std::shared_ptr BevelV2Node::createRenderData() if (shapeProp) data->shape = shapeProp->index; + auto invertProp = static_cast(this->getProp("invert")); + if (invertProp) + data->invert = invertProp->value; + + auto scaleInvariantProp = + static_cast(this->getProp("scaleInvariant")); + if (scaleInvariantProp) + data->scaleInvariant = scaleInvariantProp->value; + return data; } From ec0ee1584e6cc05407098cee98050de9aade8864 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Fri, 26 Jun 2026 12:19:06 -0500 Subject: [PATCH 101/164] add sentry for error tracking --- .github/workflows/build.yml | 18 +++++- CMakeLists.txt | 11 ++++ src/texturelab/CMakeLists.txt | 29 ++++++++-- src/texturelab/graphics/renderworker.cpp | 8 +++ src/texturelab/main.cpp | 58 ++++++++++++++++++- src/texturelab/mainwindow.cpp | 16 +++++ src/texturelab/telemetry.cpp | 74 ++++++++++++++++++++++++ src/texturelab/telemetry.h | 19 ++++++ 8 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 src/texturelab/telemetry.cpp create mode 100644 src/texturelab/telemetry.h diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 377b75d4..1f1dbbf1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,7 +41,7 @@ jobs: libxcb-shape0-dev - name: Configure CMake - run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release + run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" - name: Build run: cmake --build build --target texturelab --parallel $(nproc) @@ -73,6 +73,10 @@ jobs: # Create a placeholder icon (256x256 PNG with "TL" text) cp src/icons/logo.png texturelab.png + # Bundle crashpad_handler alongside the main binary + cp build/src/texturelab/crashpad_handler AppDir/usr/bin/ || \ + cp build/_deps/sentry-build/crashpad_build/handler/crashpad_handler AppDir/usr/bin/ + linuxdeploy-x86_64.AppImage \ --appdir AppDir \ --executable build/src/texturelab/texturelab \ @@ -108,7 +112,7 @@ jobs: cache: true - name: Configure CMake - run: cmake -B build -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=Release + run: cmake -B build -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=Release -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" - name: Build run: cmake --build build --target texturelab --config Release --parallel @@ -118,6 +122,7 @@ jobs: mkdir deploy copy build\src\texturelab\Release\texturelab.exe deploy\ ${{ github.workspace }}\Qt\Qt\6.7.0\msvc2019_64\bin\windeployqt.exe deploy\texturelab.exe --release --no-translations + copy build\src\texturelab\crashpad_handler.exe deploy\ 2>nul || copy build\_deps\sentry-build\crashpad_build\handler\Release\crashpad_handler.exe deploy\ - name: Upload Windows artifact uses: actions/upload-artifact@v4 @@ -145,11 +150,18 @@ jobs: cache: true - name: Configure CMake - run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release + run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" - name: Build run: cmake --build build --target texturelab --parallel $(sysctl -n hw.ncpu) + - name: Bundle crashpad_handler into .app + run: | + cp build/src/texturelab/crashpad_handler \ + build/src/texturelab/texturelab.app/Contents/MacOS/ 2>/dev/null || \ + cp build/_deps/sentry-build/crashpad_build/handler/crashpad_handler \ + build/src/texturelab/texturelab.app/Contents/MacOS/ + - name: Upload macOS artifact uses: actions/upload-artifact@v4 with: diff --git a/CMakeLists.txt b/CMakeLists.txt index c289843b..4573e0b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,6 +2,17 @@ cmake_minimum_required(VERSION 3.10) project(qtcompleteapp VERSION 0.1 LANGUAGES CXX) +# sentry-native (Crashpad backend for out-of-process crash capture) +include(FetchContent) +FetchContent_Declare( + sentry + GIT_REPOSITORY https://github.com/getsentry/sentry-native.git + GIT_TAG 0.7.20 +) +set(SENTRY_BACKEND "crashpad" CACHE STRING "" FORCE) +set(SENTRY_BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(sentry) + # Find Qt6 with GuiPrivate before adding subdirectories that need it find_package(Qt6 COMPONENTS Core Gui Widgets REQUIRED) diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index c7980a19..7e886481 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -146,6 +146,8 @@ set(LIBRARYV3 set(PROJECT_SOURCES ./main.cpp + ./telemetry.h + ./telemetry.cpp ./mainwindow.cpp ./mainwindow.h ./clipboard.h @@ -247,14 +249,15 @@ else() endif() # note: openglwidgets is qt6 only -target_link_libraries(texturelab PRIVATE Qt${QT_VERSION_MAJOR}::Widgets - Qt${QT_VERSION_MAJOR}::OpenGL - Qt${QT_VERSION_MAJOR}::OpenGLWidgets +target_link_libraries(texturelab PRIVATE Qt${QT_VERSION_MAJOR}::Widgets + Qt${QT_VERSION_MAJOR}::OpenGL + Qt${QT_VERSION_MAJOR}::OpenGLWidgets OpenGL::GL - qtadvanceddocking-qt6 + qtadvanceddocking-qt6 nodegraph viewer3d colorpicker + sentry ) target_include_directories(texturelab PUBLIC @@ -264,6 +267,24 @@ target_include_directories(texturelab PUBLIC ../colorpicker ) +# DSN is empty by default for local builds (Sentry init becomes a no-op) +if(NOT DEFINED TEXTURELAB_SENTRY_DSN) + set(TEXTURELAB_SENTRY_DSN "") +endif() + +target_compile_definitions(texturelab PRIVATE + TEXTURELAB_VERSION="0.4.0-beta" + TEXTURELAB_SENTRY_DSN="${TEXTURELAB_SENTRY_DSN}" +) + +# Copy crashpad_handler next to the executable so handler_path resolves at runtime +add_custom_command(TARGET texturelab POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMENT "Copying crashpad_handler next to texturelab" +) + set_target_properties(texturelab PROPERTIES MACOSX_BUNDLE_GUI_IDENTIFIER io.texturelab.app MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION} diff --git a/src/texturelab/graphics/renderworker.cpp b/src/texturelab/graphics/renderworker.cpp index d777d58d..9661b7d3 100644 --- a/src/texturelab/graphics/renderworker.cpp +++ b/src/texturelab/graphics/renderworker.cpp @@ -1,5 +1,6 @@ #include "renderworker.h" #include "../models.h" +#include "../telemetry.h" #include "../curve.h" #include "gradient.h" #include "texturerenderer.h" @@ -84,6 +85,8 @@ void RenderWorker::setup() { running = true; + Telemetry::breadcrumb("render.setup", "RenderWorker::setup() start"); + // Surface must already be created via initSurface() from main thread if (!surface || !surface->isValid()) { qFatal("Surface not initialized! Call initSurface() from main thread before run()"); @@ -100,6 +103,7 @@ void RenderWorker::setup() qFatal("unable to create surface!"); } + Telemetry::breadcrumb("render.setup", "OpenGL context created, making current"); ctx->makeCurrent(surface); // https://doc-snapshots.qt.io/qt6-dev/gui-changes-qt6.html @@ -206,6 +210,8 @@ void RenderWorker::setup() // gl->glReadBuffer(GL_NONE); gl->glBindFramebuffer(GL_FRAMEBUFFER, 0); + Telemetry::breadcrumb("render.setup", "RenderWorker::setup() complete"); + // Initialize resource cache for custom node renderers resourceCache.init(gl, fboId); @@ -223,6 +229,8 @@ void RenderWorker::setup() void RenderWorker::processRenderCommand(const RenderCommand& command) { + Telemetry::breadcrumb("render", "node: " + command.nodeId.toStdString()); + if (rdoc_api) rdoc_api->StartFrameCapture(NULL, NULL); diff --git a/src/texturelab/main.cpp b/src/texturelab/main.cpp index 6cfd2fec..98475c39 100644 --- a/src/texturelab/main.cpp +++ b/src/texturelab/main.cpp @@ -1,6 +1,8 @@ #include "mainwindow.h" +#include "telemetry.h" #include +#include #include // Hints that a dedicated GPU should be used whenever possible @@ -12,22 +14,72 @@ __declspec(dllexport) int AmdPowerXpressRequestHighPerformance = 1; } #endif +// Forward qWarning/qCritical as Sentry breadcrumbs; qFatal as a captured event +// so we get context before Crashpad's abort handler fires. +static void qtMessageHandler(QtMsgType type, const QMessageLogContext& /*ctx*/, + const QString& msg) +{ + switch (type) { + case QtDebugMsg: + break; + case QtInfoMsg: + Telemetry::breadcrumb("qt.info", msg.toStdString()); + break; + case QtWarningMsg: + Telemetry::breadcrumb("qt.warning", msg.toStdString()); + break; + case QtCriticalMsg: + Telemetry::breadcrumb("qt.critical", msg.toStdString()); + break; + case QtFatalMsg: + Telemetry::captureException("qFatal: " + msg.toStdString()); + // Allow default abort() so Crashpad captures the minidump + abort(); + } +} + int main(int argc, char* argv[]) { + // Read opt-out before constructing QApplication so we can use QSettings + // with an explicit scope (no org/app name set yet). + QSettings settings(QSettings::UserScope, "texturelab", "texturelab"); + bool crashReportingEnabled = + settings.value("crashReporting", true).toBool(); + + // Init Sentry before QApplication; resolves paths via Qt helpers after + // QCoreApplication is available (handler_path needs applicationDirPath). + // We pass a temporary QCoreApplication for path resolution, then tear it + // down before the real QApplication is constructed. + // + // Actually: sentry_options_set_handler_path / database_path only need the + // strings — we can derive them from argv[0] or defer to after QApplication. + // Simplest: init Sentry after QApplication (Crashpad handler is separate + // process anyway so it doesn't need QApplication to be alive). + QCoreApplication::setAttribute(Qt::AA_ShareOpenGLContexts); QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL); QSurfaceFormat format; format.setProfile(QSurfaceFormat::CoreProfile); format.setVersion(3, 2); - // format.setColorSpace(QSurfaceFormat::sRGBColorSpace); QSurfaceFormat::setDefaultFormat(format); QApplication a(argc, argv); - MainWindow w; + a.setOrganizationName("texturelab"); + a.setApplicationName("texturelab"); + a.setApplicationVersion(TEXTURELAB_VERSION); + + // Now applicationDirPath() is valid — init Sentry + Telemetry::init(crashReportingEnabled); + // Install message handler after Sentry is up so breadcrumbs are captured + qInstallMessageHandler(qtMessageHandler); + + MainWindow w; w.show(); w.showMaximized(); - return a.exec(); + int ret = a.exec(); + Telemetry::close(); + return ret; } diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 2bec92c7..ea0fb1cc 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -30,6 +30,7 @@ #include "DockSplitter.h" #include "exporter.h" +#include "telemetry.h" #include "undo/undocommands.h" #include "widgets/aboutdialog.h" #include "widgets/exportdialog.h" @@ -441,6 +442,17 @@ void MainWindow::setupMenus() AboutDialog dialog(this); dialog.exec(); }); + + optionsMenu->addSeparator(); + + auto crashReportingAction = optionsMenu->addAction("Send Anonymous Crash Reports"); + crashReportingAction->setCheckable(true); + QSettings settings(QSettings::UserScope, "texturelab", "texturelab"); + crashReportingAction->setChecked(settings.value("crashReporting", true).toBool()); + connect(crashReportingAction, &QAction::toggled, [](bool checked) { + QSettings s(QSettings::UserScope, "texturelab", "texturelab"); + s.setValue("crashReporting", checked); + }); } void MainWindow::setupToolbar() @@ -613,6 +625,7 @@ void MainWindow::openProjectFromPath(const QString& filePath) project->name = fileInfo.baseName(); project->filePath = filePath; + Telemetry::breadcrumb("project", "open: " + fileInfo.baseName().toStdString()); setProject(project); addToRecentFiles(filePath); } @@ -690,6 +703,7 @@ void MainWindow::newProject() { if (!promptSaveIfDirty()) return; + Telemetry::breadcrumb("project", "new project"); setProject(TextureProject::createEmpty()); } @@ -711,6 +725,7 @@ void MainWindow::saveProject() graphWidget->syncPositionsToModel(); + Telemetry::breadcrumb("project", "save: " + project->name.toStdString()); QFile file(project->filePath); file.open(QIODevice::WriteOnly); file.write(Project::saveTexture(project)); @@ -797,6 +812,7 @@ void MainWindow::directExport() void MainWindow::handleExport(const QString& destination, const QString& pattern) { + Telemetry::breadcrumb("project", "export to: " + destination.toStdString()); if (!this->project || !this->renderer) { QMessageBox::warning(this, "Export Error", "No project loaded or renderer not initialized."); diff --git a/src/texturelab/telemetry.cpp b/src/texturelab/telemetry.cpp new file mode 100644 index 00000000..a3d6b0d5 --- /dev/null +++ b/src/texturelab/telemetry.cpp @@ -0,0 +1,74 @@ +#include "telemetry.h" + +#include + +#include +#include +#include + +static bool g_enabled = false; + +void Telemetry::init(bool enabled) +{ + // TEXTURELAB_SENTRY_DSN is injected at compile time from CMake. + // Fall back to the hardcoded DSN for local dev builds. + const char* dsn = TEXTURELAB_SENTRY_DSN; + if (!dsn || dsn[0] == '\0') + dsn = "https://93029fba3adb2d1782affd712d013a2b@o216182.ingest.us.sentry.io/4511628329680896"; + if (!enabled) { + g_enabled = false; + return; + } + + sentry_options_t* options = sentry_options_new(); + + sentry_options_set_dsn(options, dsn); + sentry_options_set_release(options, "texturelab@" TEXTURELAB_VERSION); + sentry_options_set_send_default_pii(options, 0); + sentry_options_set_debug(options, 0); + + // Writable per-user directory for Crashpad's crash database + QString dataDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + + "/sentry"; + QDir().mkpath(dataDir); + sentry_options_set_database_path(options, dataDir.toStdString().c_str()); + + // crashpad_handler lives next to the texturelab executable + QString handlerPath = QCoreApplication::applicationDirPath() +#ifdef Q_OS_WIN + + "/crashpad_handler.exe"; +#else + + "/crashpad_handler"; +#endif + sentry_options_set_handler_path(options, handlerPath.toStdString().c_str()); + + sentry_init(options); + g_enabled = true; +} + +void Telemetry::close() +{ + if (g_enabled) + sentry_close(); +} + +void Telemetry::breadcrumb(const char* category, const std::string& message) +{ + if (!g_enabled) + return; + + sentry_value_t crumb = sentry_value_new_breadcrumb("default", message.c_str()); + sentry_value_set_by_key(crumb, "category", sentry_value_new_string(category)); + sentry_add_breadcrumb(crumb); +} + +void Telemetry::captureException(const std::string& message) +{ + if (!g_enabled) + return; + + sentry_value_t event = sentry_value_new_event(); + sentry_value_t exc = sentry_value_new_exception("Exception", message.c_str()); + sentry_event_add_exception(event, exc); + sentry_capture_event(event); +} diff --git a/src/texturelab/telemetry.h b/src/texturelab/telemetry.h new file mode 100644 index 00000000..1846150c --- /dev/null +++ b/src/texturelab/telemetry.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace Telemetry { + +// Call before QApplication. No-op if DSN is empty or user opted out. +void init(bool enabled); + +// Call after a.exec() returns to flush queued events. +void close(); + +// Add a breadcrumb (category + message) to the current session context. +void breadcrumb(const char* category, const std::string& message); + +// Capture a handled exception (e.g. from a catch block) as a Sentry error event. +void captureException(const std::string& message); + +} // namespace Telemetry From 7438f0aa906e327f430b703ff3c45e6e48da0fa2 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Fri, 26 Jun 2026 13:56:13 -0500 Subject: [PATCH 102/164] fix missing curl dep --- .github/workflows/build.yml | 9 +++++---- CMakeLists.txt | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1f1dbbf1..a5e729db 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,10 +38,11 @@ jobs: libxkbcommon-dev \ libvulkan-dev \ libxcb-cursor0 \ - libxcb-shape0-dev + libxcb-shape0-dev \ + libcurl4-openssl-dev - name: Configure CMake - run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" + run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release -DSENTRY_BACKEND=crashpad -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" - name: Build run: cmake --build build --target texturelab --parallel $(nproc) @@ -112,7 +113,7 @@ jobs: cache: true - name: Configure CMake - run: cmake -B build -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=Release -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" + run: cmake -B build -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=Release -DSENTRY_BACKEND=crashpad -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" - name: Build run: cmake --build build --target texturelab --config Release --parallel @@ -150,7 +151,7 @@ jobs: cache: true - name: Configure CMake - run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" + run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release -DSENTRY_BACKEND=crashpad -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" - name: Build run: cmake --build build --target texturelab --parallel $(sysctl -n hw.ncpu) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4573e0b9..24ffdff6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,8 +9,9 @@ FetchContent_Declare( GIT_REPOSITORY https://github.com/getsentry/sentry-native.git GIT_TAG 0.7.20 ) -set(SENTRY_BACKEND "crashpad" CACHE STRING "" FORCE) set(SENTRY_BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +set(SENTRY_BACKEND "crashpad" CACHE STRING "" FORCE) +set(SENTRY_TRANSPORT "curl" CACHE STRING "" FORCE) FetchContent_MakeAvailable(sentry) # Find Qt6 with GuiPrivate before adding subdirectories that need it From f18f59e7f73174bacfcd910057538be971ca0b4c Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Fri, 26 Jun 2026 14:23:33 -0500 Subject: [PATCH 103/164] tidy up telemetry --- src/texturelab/CMakeLists.txt | 14 ++++++++------ src/texturelab/telemetry.cpp | 1 - 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 7e886481..73bf4ba8 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -278,12 +278,14 @@ target_compile_definitions(texturelab PRIVATE ) # Copy crashpad_handler next to the executable so handler_path resolves at runtime -add_custom_command(TARGET texturelab POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - $ - $ - COMMENT "Copying crashpad_handler next to texturelab" -) +if(TARGET crashpad_handler) + add_custom_command(TARGET texturelab POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMENT "Copying crashpad_handler next to texturelab" + ) +endif() set_target_properties(texturelab PROPERTIES MACOSX_BUNDLE_GUI_IDENTIFIER io.texturelab.app diff --git a/src/texturelab/telemetry.cpp b/src/texturelab/telemetry.cpp index a3d6b0d5..766f98fb 100644 --- a/src/texturelab/telemetry.cpp +++ b/src/texturelab/telemetry.cpp @@ -24,7 +24,6 @@ void Telemetry::init(bool enabled) sentry_options_set_dsn(options, dsn); sentry_options_set_release(options, "texturelab@" TEXTURELAB_VERSION); - sentry_options_set_send_default_pii(options, 0); sentry_options_set_debug(options, 0); // Writable per-user directory for Crashpad's crash database From 17ee858faa70bbfbb32ebcdacc907f60f8d4aed9 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Fri, 26 Jun 2026 15:43:17 -0500 Subject: [PATCH 104/164] add git hash to version --- src/texturelab/CMakeLists.txt | 13 ++++++++++++- src/texturelab/GetGitHash.cmake | 10 ++++++++++ src/texturelab/main.cpp | 3 ++- src/texturelab/telemetry.cpp | 3 ++- src/texturelab/version.h.in | 2 ++ 5 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 src/texturelab/GetGitHash.cmake create mode 100644 src/texturelab/version.h.in diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 73bf4ba8..61a9351c 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -266,7 +266,6 @@ target_include_directories(texturelab PUBLIC ../viewer3d ../colorpicker ) - # DSN is empty by default for local builds (Sentry init becomes a no-op) if(NOT DEFINED TEXTURELAB_SENTRY_DSN) set(TEXTURELAB_SENTRY_DSN "") @@ -277,6 +276,18 @@ target_compile_definitions(texturelab PRIVATE TEXTURELAB_SENTRY_DSN="${TEXTURELAB_SENTRY_DSN}" ) +# Generate version.h (with git hash) at every build, not just configure time +add_custom_target(texturelab_version + COMMAND ${CMAKE_COMMAND} + -DSOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}" + -DBINARY_DIR="${CMAKE_CURRENT_BINARY_DIR}" + -P "${CMAKE_CURRENT_SOURCE_DIR}/GetGitHash.cmake" + BYPRODUCTS "${CMAKE_CURRENT_BINARY_DIR}/version.h" + COMMENT "Generating version.h" +) +add_dependencies(texturelab texturelab_version) +target_include_directories(texturelab PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") + # Copy crashpad_handler next to the executable so handler_path resolves at runtime if(TARGET crashpad_handler) add_custom_command(TARGET texturelab POST_BUILD diff --git a/src/texturelab/GetGitHash.cmake b/src/texturelab/GetGitHash.cmake new file mode 100644 index 00000000..bb920925 --- /dev/null +++ b/src/texturelab/GetGitHash.cmake @@ -0,0 +1,10 @@ +execute_process( + COMMAND git -C "${SOURCE_DIR}" rev-parse --short HEAD + OUTPUT_VARIABLE TEXTURELAB_BUILD_HASH + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET +) +if(NOT TEXTURELAB_BUILD_HASH) + set(TEXTURELAB_BUILD_HASH "unknown") +endif() +configure_file("${SOURCE_DIR}/version.h.in" "${BINARY_DIR}/version.h" @ONLY) diff --git a/src/texturelab/main.cpp b/src/texturelab/main.cpp index 98475c39..cb8cd04e 100644 --- a/src/texturelab/main.cpp +++ b/src/texturelab/main.cpp @@ -1,5 +1,6 @@ #include "mainwindow.h" #include "telemetry.h" +#include "version.h" #include #include @@ -67,7 +68,7 @@ int main(int argc, char* argv[]) QApplication a(argc, argv); a.setOrganizationName("texturelab"); a.setApplicationName("texturelab"); - a.setApplicationVersion(TEXTURELAB_VERSION); + a.setApplicationVersion(QString(TEXTURELAB_VERSION) + "+" + TEXTURELAB_BUILD_HASH); // Now applicationDirPath() is valid — init Sentry Telemetry::init(crashReportingEnabled); diff --git a/src/texturelab/telemetry.cpp b/src/texturelab/telemetry.cpp index 766f98fb..26b91ccd 100644 --- a/src/texturelab/telemetry.cpp +++ b/src/texturelab/telemetry.cpp @@ -1,4 +1,5 @@ #include "telemetry.h" +#include "version.h" #include @@ -23,7 +24,7 @@ void Telemetry::init(bool enabled) sentry_options_t* options = sentry_options_new(); sentry_options_set_dsn(options, dsn); - sentry_options_set_release(options, "texturelab@" TEXTURELAB_VERSION); + sentry_options_set_release(options, "texturelab@" TEXTURELAB_VERSION "+" TEXTURELAB_BUILD_HASH); sentry_options_set_debug(options, 0); // Writable per-user directory for Crashpad's crash database diff --git a/src/texturelab/version.h.in b/src/texturelab/version.h.in new file mode 100644 index 00000000..242faa38 --- /dev/null +++ b/src/texturelab/version.h.in @@ -0,0 +1,2 @@ +#pragma once +#define TEXTURELAB_BUILD_HASH "@TEXTURELAB_BUILD_HASH@" From c59ac6c16cfdd67faf2887608bf215bef14ba439 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 27 Jun 2026 01:09:09 -0500 Subject: [PATCH 105/164] fix crashpad build issues in ci build --- .github/workflows/build.yml | 3 ++- CMakeLists.txt | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a5e729db..30307a8d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -75,6 +75,7 @@ jobs: cp src/icons/logo.png texturelab.png # Bundle crashpad_handler alongside the main binary + mkdir -p AppDir/usr/bin cp build/src/texturelab/crashpad_handler AppDir/usr/bin/ || \ cp build/_deps/sentry-build/crashpad_build/handler/crashpad_handler AppDir/usr/bin/ @@ -151,7 +152,7 @@ jobs: cache: true - name: Configure CMake - run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release -DSENTRY_BACKEND=crashpad -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" + run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release -DSENTRY_BACKEND=crashpad -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" -DCMAKE_OSX_SYSROOT=$(xcrun --show-sdk-path) - name: Build run: cmake --build build --target texturelab --parallel $(sysctl -n hw.ncpu) diff --git a/CMakeLists.txt b/CMakeLists.txt index 24ffdff6..4c347cc7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,7 +11,11 @@ FetchContent_Declare( ) set(SENTRY_BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) set(SENTRY_BACKEND "crashpad" CACHE STRING "" FORCE) -set(SENTRY_TRANSPORT "curl" CACHE STRING "" FORCE) +if(WIN32) + set(SENTRY_TRANSPORT "winhttp" CACHE STRING "" FORCE) +else() + set(SENTRY_TRANSPORT "curl" CACHE STRING "" FORCE) +endif() FetchContent_MakeAvailable(sentry) # Find Qt6 with GuiPrivate before adding subdirectories that need it From e7592681913153c62fae608709bf1d0849536a63 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 27 Jun 2026 21:04:43 -0500 Subject: [PATCH 106/164] fix ci: use inproc on macos, fix windows ps1 copy syntax - macOS: switch sentry backend to inproc to avoid crashpad/exc.defs and AGL framework issues on Xcode 16 / macOS 15 arm64 - macOS: remove CMAKE_OSX_SYSROOT and crashpad_handler bundle step - Windows: rewrite Deploy step in pwsh with Test-Path fallback so crashpad_handler.exe copy failure doesn't abort the whole step Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/build.yml | 23 +++++++++++------------ CMakeLists.txt | 6 +++++- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 30307a8d..95c30e36 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -120,11 +120,17 @@ jobs: run: cmake --build build --target texturelab --config Release --parallel - name: Deploy Qt dependencies + shell: pwsh run: | - mkdir deploy - copy build\src\texturelab\Release\texturelab.exe deploy\ - ${{ github.workspace }}\Qt\Qt\6.7.0\msvc2019_64\bin\windeployqt.exe deploy\texturelab.exe --release --no-translations - copy build\src\texturelab\crashpad_handler.exe deploy\ 2>nul || copy build\_deps\sentry-build\crashpad_build\handler\Release\crashpad_handler.exe deploy\ + New-Item -ItemType Directory -Path deploy + Copy-Item "build\src\texturelab\Release\texturelab.exe" deploy\ + & "${{ github.workspace }}\Qt\Qt\6.7.0\msvc2019_64\bin\windeployqt.exe" "deploy\texturelab.exe" --release --no-translations + $handler = @( + "build\src\texturelab\Release\crashpad_handler.exe", + "build\src\texturelab\crashpad_handler.exe", + "build\_deps\sentry-build\crashpad_build\handler\Release\crashpad_handler.exe" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if ($handler) { Copy-Item $handler deploy\ } else { Write-Warning "crashpad_handler.exe not found, skipping" } - name: Upload Windows artifact uses: actions/upload-artifact@v4 @@ -152,18 +158,11 @@ jobs: cache: true - name: Configure CMake - run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release -DSENTRY_BACKEND=crashpad -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" -DCMAKE_OSX_SYSROOT=$(xcrun --show-sdk-path) + run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" - name: Build run: cmake --build build --target texturelab --parallel $(sysctl -n hw.ncpu) - - name: Bundle crashpad_handler into .app - run: | - cp build/src/texturelab/crashpad_handler \ - build/src/texturelab/texturelab.app/Contents/MacOS/ 2>/dev/null || \ - cp build/_deps/sentry-build/crashpad_build/handler/crashpad_handler \ - build/src/texturelab/texturelab.app/Contents/MacOS/ - - name: Upload macOS artifact uses: actions/upload-artifact@v4 with: diff --git a/CMakeLists.txt b/CMakeLists.txt index 4c347cc7..a5226d31 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,10 +10,14 @@ FetchContent_Declare( GIT_TAG 0.7.20 ) set(SENTRY_BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) -set(SENTRY_BACKEND "crashpad" CACHE STRING "" FORCE) if(WIN32) + set(SENTRY_BACKEND "crashpad" CACHE STRING "" FORCE) set(SENTRY_TRANSPORT "winhttp" CACHE STRING "" FORCE) +elseif(APPLE) + set(SENTRY_BACKEND "inproc" CACHE STRING "" FORCE) + set(SENTRY_TRANSPORT "curl" CACHE STRING "" FORCE) else() + set(SENTRY_BACKEND "crashpad" CACHE STRING "" FORCE) set(SENTRY_TRANSPORT "curl" CACHE STRING "" FORCE) endif() FetchContent_MakeAvailable(sentry) From 1ed6444314abd13abc4a5777618b75a5520df890 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Tue, 30 Jun 2026 13:18:21 -0500 Subject: [PATCH 107/164] upload debug info to sentry post build --- .github/workflows/build.yml | 42 ++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 95c30e36..28b19eca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,11 +42,25 @@ jobs: libcurl4-openssl-dev - name: Configure CMake - run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release -DSENTRY_BACKEND=crashpad -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" + run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=RelWithDebInfo -DSENTRY_BACKEND=crashpad -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" - name: Build run: cmake --build build --target texturelab --parallel $(nproc) + - name: Upload debug symbols to Sentry + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: | + curl -sL https://sentry.io/get-cli/ | bash + objcopy --only-keep-debug \ + build/src/texturelab/texturelab \ + build/src/texturelab/texturelab.debug + strip build/src/texturelab/texturelab + sentry-cli debug-files upload --include-sources \ + build/src/texturelab/texturelab.debug + - name: Install LinuxDeploy uses: miurahr/install-linuxdeploy-action@v1 with: @@ -114,11 +128,21 @@ jobs: cache: true - name: Configure CMake - run: cmake -B build -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=Release -DSENTRY_BACKEND=crashpad -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" + run: cmake -B build -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=Release -DSENTRY_BACKEND=crashpad -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" -DCMAKE_CXX_FLAGS_RELEASE="/MD /O2 /Ob2 /DNDEBUG /Zi" -DCMAKE_EXE_LINKER_FLAGS_RELEASE="/INCREMENTAL:NO /DEBUG /OPT:REF /OPT:ICF" - name: Build run: cmake --build build --target texturelab --config Release --parallel + - name: Upload debug symbols to Sentry + shell: pwsh + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: | + Invoke-WebRequest -Uri "https://github.com/getsentry/sentry-cli/releases/latest/download/sentry-cli-Windows-x86_64.exe" -OutFile "sentry-cli.exe" + .\sentry-cli.exe debug-files upload --include-sources "build\src\texturelab\Release\" + - name: Deploy Qt dependencies shell: pwsh run: | @@ -158,11 +182,23 @@ jobs: cache: true - name: Configure CMake - run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" + run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=RelWithDebInfo -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" - name: Build run: cmake --build build --target texturelab --parallel $(sysctl -n hw.ncpu) + - name: Generate dSYM and upload to Sentry + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: | + dsymutil build/src/texturelab/texturelab.app/Contents/MacOS/texturelab \ + -o texturelab.dSYM + strip build/src/texturelab/texturelab.app/Contents/MacOS/texturelab + curl -sL https://sentry.io/get-cli/ | bash + sentry-cli debug-files upload --include-sources texturelab.dSYM + - name: Upload macOS artifact uses: actions/upload-artifact@v4 with: From 26d6a25205a105025a82bfb05453eb73a689801e Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Mon, 6 Jul 2026 12:27:53 -0500 Subject: [PATCH 108/164] fix multithreading-based memory access and queueing bugs --- src/texturelab/graphics/renderworker.cpp | 4 +- src/texturelab/graphics/renderworker.h | 8 +++- src/texturelab/graphics/texturerenderer.cpp | 51 ++++++++++++++++----- src/texturelab/graphics/texturerenderer.h | 6 +++ 4 files changed, 54 insertions(+), 15 deletions(-) diff --git a/src/texturelab/graphics/renderworker.cpp b/src/texturelab/graphics/renderworker.cpp index 9661b7d3..ac7bf3eb 100644 --- a/src/texturelab/graphics/renderworker.cpp +++ b/src/texturelab/graphics/renderworker.cpp @@ -270,8 +270,8 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) } // CPU processing path - if (command.usesCpuProcessing && command.nodePtr != nullptr) { - TextureNode* node = static_cast(command.nodePtr); + if (command.usesCpuProcessing && command.nodePtr) { + TextureNode* node = command.nodePtr.data(); node->cpuProcess(gl, command); ctx->doneCurrent(); diff --git a/src/texturelab/graphics/renderworker.h b/src/texturelab/graphics/renderworker.h index 3982bd11..ec96b636 100644 --- a/src/texturelab/graphics/renderworker.h +++ b/src/texturelab/graphics/renderworker.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,9 @@ class QOpenGLShader; class QOpenGLShaderProgram; class QOpenGLFramebufferObject; +class TextureNode; +typedef QSharedPointer TextureNodePtr; + struct RenderNodeInput { QString nodeId; QString inputName; @@ -47,7 +51,9 @@ struct RenderCommand { // CPU processing support bool usesCpuProcessing = false; - void* nodePtr = nullptr; // TextureNode* pointer for CPU processing + // Shared (not raw) so the node stays alive for the lifetime of this + // command even if it's removed from the project while queued/in-flight. + TextureNodePtr nodePtr; // all expected inputs need to be cleared int totalInputs; diff --git a/src/texturelab/graphics/texturerenderer.cpp b/src/texturelab/graphics/texturerenderer.cpp index 6d643d16..6da3865f 100644 --- a/src/texturelab/graphics/texturerenderer.cpp +++ b/src/texturelab/graphics/texturerenderer.cpp @@ -343,22 +343,22 @@ void TextureRenderer::update() } // if the resolution has changed, resize texture - if (project->textureWidth != node->textureWidth || - project->textureHeight != node->textureHeight) { - // resize - // resizeNodeTexture(node); - // node->textureWidth = project->textureWidth; - // node->textureHeight = project->textureHeight; - // node->texture = new QOpenGLFramebufferObject(node->textureWidth, - // node->textureHeight); - + // Deferred while a render is in flight: the texture/FBO being + // replaced here may be captured as an input GLuint in the command + // currently queued/executing on the worker thread. Once that + // command completes, nodeRendered() re-invokes update(), which will + // pick this resize back up. + if (!renderInFlight && + (project->textureWidth != node->textureWidth || + project->textureHeight != node->textureHeight)) { this->createNodeTexture(node); // clear pixmap and emit thumbnail changed? } } - this->queueNextNodeToRender(); + if (!renderInFlight) + this->queueNextNodeToRender(); } void TextureRenderer::updateOld() @@ -431,6 +431,14 @@ void TextureRenderer::createNodeTexture(const TextureNodePtr& node) { ctx->makeCurrent(surface); + // Safe to free now: callers only reach here when !renderInFlight, so no + // queued/executing RenderCommand can be holding this texture's GLuint + // as an input. + if (node->texture) { + delete node->texture; + node->texture = nullptr; + } + // create fbo QOpenGLFramebufferObjectFormat fboFormat; fboFormat.setInternalTextureFormat(GL_RGBA32F); @@ -449,6 +457,10 @@ void TextureRenderer::createNodeTexture(const TextureNodePtr& node) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); gl->glBindTexture(GL_TEXTURE_2D, 0); + // This texture ID is shared into the render worker's context on another + // thread; flush so its creation is visible there before it's used. + gl->glFlush(); + ctx->doneCurrent(); } @@ -597,8 +609,11 @@ void TextureRenderer::initRenderWorker() void TextureRenderer::nodeRendered(const QString& nodeId, GLuint texId) { qDebug() << "TextureRenderer: Node rendered:" << nodeId; + renderInFlight = false; emit thumbnailGenerated(nodeId, texId, QPixmap()); - this->queueNextNodeToRender(); + // update() re-checks pending resizes deferred while a render was in + // flight, then queues the next node. + this->update(); if (project) { int total = project->nodes.size(); int clean = 0; @@ -630,7 +645,10 @@ void TextureRenderer::queueNextNodeToRender() // CPU processing support cmd.usesCpuProcessing = nextNode->usesCpuProcessing; - cmd.nodePtr = nextNode.data(); // Store raw pointer for CPU processing + // Keep the node alive for the lifetime of the command (it may still + // be queued or mid-render on the worker thread if the node is + // removed from the project in the meantime). + cmd.nodePtr = nextNode; cmd.totalInputs = nextNode->inputs.size(); @@ -661,6 +679,10 @@ void TextureRenderer::queueNextNodeToRender() rnp.textureId = imageProp->getTextureId(); + // Shared into the render worker's context on another + // thread; flush so the upload is visible there. + gl->glFlush(); + ctx->doneCurrent(); } } @@ -676,6 +698,7 @@ void TextureRenderer::queueNextNodeToRender() // pass to render worker to process renderWorker->setRenderQueue(queue); + renderInFlight = true; // mark node as clean before rendering to avoid double-queuing nextNode->isDirty = false; @@ -774,6 +797,10 @@ TextureRenderer::buildShaderForNode(const TextureNodePtr& node) qDebug() << program->log(); } + // Shared into the render worker's context on another thread; flush so + // the link is visible there before glUseProgram() is called on it. + gl->glFlush(); + ctx->doneCurrent(); return program; diff --git a/src/texturelab/graphics/texturerenderer.h b/src/texturelab/graphics/texturerenderer.h index 1213084e..79e9b0dd 100644 --- a/src/texturelab/graphics/texturerenderer.h +++ b/src/texturelab/graphics/texturerenderer.h @@ -39,6 +39,12 @@ class TextureRenderer : public QObject { QThread* renderThread; RenderWorker* renderWorker; + // True from the moment a RenderCommand is handed to the worker until its + // nodeRendered() callback fires. GUI-thread only. While true, node + // textures/FBOs must not be resized/recreated: their GLuints may be + // captured as inputs in the in-flight command. + bool renderInFlight = false; + public: TextureRenderer(); ~TextureRenderer(); From 992c73bfc12ad5bb7779a97fc6965ec1de140a24 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Wed, 15 Jul 2026 12:43:19 -0500 Subject: [PATCH 109/164] add crash testing inside main gated by flag --- scripts/sentry-local-test.sh | 56 ++++++++++++++++++++++++++++++++++++ src/texturelab/main.cpp | 25 ++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100755 scripts/sentry-local-test.sh diff --git a/scripts/sentry-local-test.sh b/scripts/sentry-local-test.sh new file mode 100755 index 00000000..47239b14 --- /dev/null +++ b/scripts/sentry-local-test.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Local end-to-end test for Sentry crash symbolication (Linux). +# +# Mirrors what CI does, then triggers a real crash so you can confirm the +# stack trace is readable in sentry.io. +# +# Requires: sentry-cli on PATH, and these env vars: +# SENTRY_AUTH_TOKEN – an auth token with project:write / project:releases +# SENTRY_ORG – your Sentry org slug +# SENTRY_PROJECT – your Sentry project slug +# +# Usage: +# SENTRY_AUTH_TOKEN=xxx SENTRY_ORG=xxx SENTRY_PROJECT=xxx \ +# ./scripts/sentry-local-test.sh [build-dir] +set -euo pipefail + +BUILD_DIR="${1:-build-sentry-test}" +BIN="$BUILD_DIR/src/texturelab/texturelab" + +: "${SENTRY_AUTH_TOKEN:?set SENTRY_AUTH_TOKEN}" +: "${SENTRY_ORG:?set SENTRY_ORG}" +: "${SENTRY_PROJECT:?set SENTRY_PROJECT}" + +if [ ! -f "$BIN" ]; then + echo "!! $BIN not found. Build first:" + echo " cmake --build $BUILD_DIR --target texturelab --parallel \$(nproc)" + exit 1 +fi + +echo "== 1) Inspecting DIF of the freshly built binary ==" +sentry-cli debug-files check "$BIN" + +echo +echo "== 2) Uploading FULL unstripped binary (debug + unwind + sources) ==" +sentry-cli debug-files upload --include-sources "$BIN" + +echo +echo "== 3) Stripping the shipped copy (build-id / Debug ID is preserved) ==" +strip "$BIN" +sentry-cli debug-files check "$BIN" # should still show a matching Debug ID + +echo +echo "== 4) Triggering a deliberate crash so Crashpad uploads a minidump ==" +# Wipe any stale crash DB so we know the minidump is from this run. +rm -rf "$HOME/.local/share/texturelab/texturelab/sentry" 2>/dev/null || true +set +e +"$BIN" --sentry-crash-test +echo " app exited with code $? (a crash is expected)" +set -e + +echo +echo "== Done ==" +echo "Crashpad uploads the minidump in the background. Open your Sentry project:" +echo " https://$SENTRY_ORG.sentry.io/issues/" +echo "You should see a new crash whose stack trace includes 'sentryCrashTest'" +echo "and 'main' with file/line info. If the frames are symbolicated, the fix works." diff --git a/src/texturelab/main.cpp b/src/texturelab/main.cpp index cb8cd04e..6d08fcf4 100644 --- a/src/texturelab/main.cpp +++ b/src/texturelab/main.cpp @@ -6,6 +6,8 @@ #include #include +#include + // Hints that a dedicated GPU should be used whenever possible // https://stackoverflow.com/a/39047129/991834 #ifdef Q_OS_WIN @@ -39,6 +41,22 @@ static void qtMessageHandler(QtMsgType type, const QMessageLogContext& /*ctx*/, } } +#if defined(_MSC_VER) +#define TL_NOINLINE __declspec(noinline) +#else +#define TL_NOINLINE __attribute__((noinline)) +#endif + +// Deliberately dereference a null pointer so Crashpad captures a minidump. +// Kept in a named, non-inlined function so the symbolicated Sentry stack trace +// shows a recognizable frame. Triggered only via the --sentry-crash-test flag; +// remove this hook once symbolication is confirmed in production. +TL_NOINLINE static void sentryCrashTest() +{ + volatile int* p = nullptr; + *p = 0xC0FFEE; +} + int main(int argc, char* argv[]) { // Read opt-out before constructing QApplication so we can use QSettings @@ -73,6 +91,13 @@ int main(int argc, char* argv[]) // Now applicationDirPath() is valid — init Sentry Telemetry::init(crashReportingEnabled); + // Crash-test hook for verifying Sentry symbolication end-to-end. + // Must run after Telemetry::init so Crashpad is armed to catch it. + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--sentry-crash-test") == 0) + sentryCrashTest(); + } + // Install message handler after Sentry is up so breadcrumbs are captured qInstallMessageHandler(qtMessageHandler); From a44f0c1f45c20799add88cd32df0095d581cae25 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Wed, 15 Jul 2026 12:43:36 -0500 Subject: [PATCH 110/164] fix symbolification generation in gh action for linux --- .github/workflows/build.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 28b19eca..5cf37901 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,12 +54,16 @@ jobs: SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} run: | curl -sL https://sentry.io/get-cli/ | bash - objcopy --only-keep-debug \ - build/src/texturelab/texturelab \ - build/src/texturelab/texturelab.debug - strip build/src/texturelab/texturelab + # Upload the FULL, unstripped binary. It contains debug info AND the + # .eh_frame unwind info (CFI) that Sentry needs to walk a minidump's + # stack. objcopy --only-keep-debug drops the unwind info, which left + # crashes unsymbolicated ("debug information files are missing"). sentry-cli debug-files upload --include-sources \ - build/src/texturelab/texturelab.debug + build/src/texturelab/texturelab + # Strip the shipped copy to shrink the AppImage. The GNU build-id + # (== Sentry Debug ID) survives stripping, so the DIF we just + # uploaded still matches the binary that ships and crashes. + strip build/src/texturelab/texturelab - name: Install LinuxDeploy uses: miurahr/install-linuxdeploy-action@v1 From 12b2f1ce0eb04210f9c2d07aae8169128bdc4a7d Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Wed, 15 Jul 2026 13:05:54 -0500 Subject: [PATCH 111/164] add manual windows crash to test symbolication in sentry --- .github/workflows/build.yml | 38 ++++++++++++++++++++++++++++++++++- .gitignore | 6 +++++- src/texturelab/CMakeLists.txt | 10 +++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5cf37901..e4e2f5ab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,11 @@ on: pull_request: branches: [main, master] workflow_dispatch: + inputs: + sentry_crash_test: + description: "After building, run --sentry-crash-test to send a real crash to Sentry (verifies symbolication)" + type: boolean + default: false jobs: build-linux: @@ -65,6 +70,16 @@ jobs: # uploaded still matches the binary that ships and crashes. strip build/src/texturelab/texturelab + - name: Sentry crash test (manual) + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sentry_crash_test == 'true' }} + run: | + export QT_QPA_PLATFORM=offscreen + # Crashes before the main window opens; crashpad_handler (copied next to + # the binary at build time) uploads the minidump via the built-in DSN. + build/src/texturelab/texturelab --sentry-crash-test || true + echo "waiting for crashpad to upload the minidump..." + sleep 25 + - name: Install LinuxDeploy uses: miurahr/install-linuxdeploy-action@v1 with: @@ -132,7 +147,7 @@ jobs: cache: true - name: Configure CMake - run: cmake -B build -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=Release -DSENTRY_BACKEND=crashpad -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" -DCMAKE_CXX_FLAGS_RELEASE="/MD /O2 /Ob2 /DNDEBUG /Zi" -DCMAKE_EXE_LINKER_FLAGS_RELEASE="/INCREMENTAL:NO /DEBUG /OPT:REF /OPT:ICF" + run: cmake -B build -G "Visual Studio 17 2022" -A x64 -DSENTRY_BACKEND=crashpad -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" - name: Build run: cmake --build build --target texturelab --config Release --parallel @@ -145,6 +160,13 @@ jobs: SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} run: | Invoke-WebRequest -Uri "https://github.com/getsentry/sentry-cli/releases/latest/download/sentry-cli-Windows-x86_64.exe" -OutFile "sentry-cli.exe" + $exe = "build\src\texturelab\Release\texturelab.exe" + $pdb = "build\src\texturelab\Release\texturelab.pdb" + if (-not (Test-Path $pdb)) { Write-Error "texturelab.pdb not found — build produced no debug info; Sentry cannot symbolicate."; exit 1 } + # Log the Debug IDs. The exe's Debug ID (from its CodeView record) MUST + # be non-null and match the pdb, or crash minidumps stay unsymbolicated. + Write-Host "== exe Debug ID =="; .\sentry-cli.exe debug-files check $exe + Write-Host "== pdb Debug ID =="; .\sentry-cli.exe debug-files check $pdb .\sentry-cli.exe debug-files upload --include-sources "build\src\texturelab\Release\" - name: Deploy Qt dependencies @@ -160,6 +182,20 @@ jobs: ) | Where-Object { Test-Path $_ } | Select-Object -First 1 if ($handler) { Copy-Item $handler deploy\ } else { Write-Warning "crashpad_handler.exe not found, skipping" } + - name: Sentry crash test (manual) + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sentry_crash_test == 'true' }} + shell: pwsh + run: | + $env:QT_QPA_PLATFORM = "offscreen" + # Run the deployed bundle (Qt DLLs + crashpad_handler.exe alongside the + # exe). Crashes before the main window; crashpad uploads the minidump + # via the built-in DSN. Debug IDs match the PDB uploaded above. + $p = Start-Process -FilePath "deploy\texturelab.exe" -ArgumentList "--sentry-crash-test" -PassThru + if (-not $p.WaitForExit(60000)) { $p.Kill(); Write-Warning "timed out waiting for crash" } + else { Write-Host "app exited with code $($p.ExitCode) (crash expected)" } + Write-Host "waiting for crashpad to upload the minidump..." + Start-Sleep -Seconds 25 + - name: Upload Windows artifact uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index 6c8746dc..06a5cb00 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,8 @@ compile_commands.json CTestTestfile.cmake _deps -build/ \ No newline at end of file +build/ +build-sentry-test/ + +# Secrets — Sentry auth token, org/project slugs +.env \ No newline at end of file diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 61a9351c..bc4ec126 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -288,6 +288,16 @@ add_custom_target(texturelab_version add_dependencies(texturelab texturelab_version) target_include_directories(texturelab PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") +# MSVC: deterministically emit a PDB and embed a CodeView record in the exe, in +# every config (incl. Release). Without the CodeView record the shipped exe has +# no debug_id, so Sentry cannot match any PDB to a crash minidump ("Unknown +# function" frames). Attaching to the target is reliable; injecting /Zi + /DEBUG +# via CMAKE_*_FLAGS_RELEASE on the command line was not (VS generator + Qt). +if(MSVC) + target_compile_options(texturelab PRIVATE /Zi) + target_link_options(texturelab PRIVATE /DEBUG /OPT:REF /OPT:ICF /INCREMENTAL:NO) +endif() + # Copy crashpad_handler next to the executable so handler_path resolves at runtime if(TARGET crashpad_handler) add_custom_command(TARGET texturelab POST_BUILD From 934a7720d73ba8977ab05fe0ea4607e552c7d1f8 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Wed, 15 Jul 2026 13:41:18 -0500 Subject: [PATCH 112/164] add manual crash test to mac build --- .github/workflows/build.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e4e2f5ab..d43ebf45 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -203,7 +203,11 @@ jobs: path: deploy/ build-macos: - runs-on: macos-latest + # macos-13 (Xcode 15) still ships AGL.framework, which Qt 6.7.0's macOS + # CMake config references. macos-latest (Xcode 16 / macOS 15 SDK) removed + # AGL, causing "ld: framework 'AGL' not found". This pins to x86_64; switch + # back to macos-latest once Qt is bumped to >= 6.7.3 (AGL reference removed). + runs-on: macos-13 steps: - name: Checkout repository @@ -239,6 +243,16 @@ jobs: curl -sL https://sentry.io/get-cli/ | bash sentry-cli debug-files upload --include-sources texturelab.dSYM + - name: Sentry crash test (manual) + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sentry_crash_test == 'true' }} + run: | + export QT_QPA_PLATFORM=offscreen + # inproc backend captures the crash in-process; the dSYM uploaded above + # (matching Mach-O UUID, preserved by strip) symbolicates it server-side. + build/src/texturelab/texturelab.app/Contents/MacOS/texturelab --sentry-crash-test || true + echo "waiting for the event to flush..." + sleep 25 + - name: Upload macOS artifact uses: actions/upload-artifact@v4 with: @@ -270,7 +284,7 @@ jobs: if [ -d texturelab-macos ]; then zip -r ../macos-$SHA.zip texturelab-macos/; fi cd .. for f in linux-$SHA.zip windows-$SHA.zip macos-$SHA.zip; do - [ -f "$f" ] && aws s3 cp "$f" s3://texturelab-nightlies/ + if [ -f "$f" ]; then aws s3 cp "$f" s3://texturelab-nightlies/; fi done - name: Post to Discord From 30bf653247a36f7a303c3b0e16c6bb8f2baa2175 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Wed, 15 Jul 2026 18:08:00 -0500 Subject: [PATCH 113/164] bump to macos version --- .github/workflows/build.yml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d43ebf45..8a49957a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -203,11 +203,12 @@ jobs: path: deploy/ build-macos: - # macos-13 (Xcode 15) still ships AGL.framework, which Qt 6.7.0's macOS - # CMake config references. macos-latest (Xcode 16 / macOS 15 SDK) removed - # AGL, causing "ld: framework 'AGL' not found". This pins to x86_64; switch - # back to macos-latest once Qt is bumped to >= 6.7.3 (AGL reference removed). - runs-on: macos-13 + # Apple Silicon runner with good availability. Qt 6.7.0's macOS CMake config + # references AGL.framework, which was removed in the macOS 15 SDK (Xcode 16). + # We pin Xcode 15 (macOS 14 SDK, AGL still present) and build a universal + # x86_64+arm64 binary so it runs on both Intel and Apple Silicon Macs. + # (Drop the Xcode pin once Qt is bumped to >= 6.7.3, which removes the AGL ref.) + runs-on: macos-14 steps: - name: Checkout repository @@ -217,6 +218,14 @@ jobs: fetch-depth: 0 fetch-tags: true + - name: Select Xcode 15 (macOS 14 SDK still ships AGL.framework) + run: | + XC=$(ls -d /Applications/Xcode_15*.app 2>/dev/null | sort -V | tail -1) + if [ -z "$XC" ]; then echo "No Xcode 15.x found on runner"; ls -d /Applications/Xcode_*.app; exit 1; fi + echo "Using $XC" + sudo xcode-select -s "$XC/Contents/Developer" + xcodebuild -version + - name: Install Qt6 uses: jurplel/install-qt-action@v4 with: @@ -226,7 +235,7 @@ jobs: cache: true - name: Configure CMake - run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=RelWithDebInfo -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" + run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_OSX_ARCHITECTURES="x86_64;arm64" -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" - name: Build run: cmake --build build --target texturelab --parallel $(sysctl -n hw.ncpu) From 5f0376c32dc6c9ae532c3bd1b024704cab27e3aa Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Wed, 15 Jul 2026 23:24:31 -0500 Subject: [PATCH 114/164] add mac inproc test --- src/texturelab/main.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/texturelab/main.cpp b/src/texturelab/main.cpp index 6d08fcf4..078de071 100644 --- a/src/texturelab/main.cpp +++ b/src/texturelab/main.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include @@ -92,10 +93,18 @@ int main(int argc, char* argv[]) Telemetry::init(crashReportingEnabled); // Crash-test hook for verifying Sentry symbolication end-to-end. - // Must run after Telemetry::init so Crashpad is armed to catch it. + // Must run after Telemetry::init so the crash handler is armed. for (int i = 1; i < argc; ++i) { - if (std::strcmp(argv[i], "--sentry-crash-test") == 0) + if (std::strcmp(argv[i], "--sentry-crash-test") == 0) { + // Give the SDK's network transport a moment to spin up before we + // crash. Crashpad (Win/Linux) uploads out-of-process so this isn't + // needed there, but the macOS inproc backend must send the event + // synchronously from the dying process — an instant crash at + // startup dies before the transport is ready. A real crash happens + // after the app has been running, so this warm-up is representative. + QThread::sleep(4); sentryCrashTest(); + } } // Install message handler after Sentry is up so breadcrumbs are captured From 5274f62f605d3af96d98349bfa48ad0cc68df779 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Wed, 15 Jul 2026 23:44:13 -0500 Subject: [PATCH 115/164] mac crash hack --- .github/workflows/build.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8a49957a..f2731b5b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -256,11 +256,17 @@ jobs: if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sentry_crash_test == 'true' }} run: | export QT_QPA_PLATFORM=offscreen - # inproc backend captures the crash in-process; the dSYM uploaded above - # (matching Mach-O UUID, preserved by strip) symbolicates it server-side. - build/src/texturelab/texturelab.app/Contents/MacOS/texturelab --sentry-crash-test || true + BIN="build/src/texturelab/texturelab.app/Contents/MacOS/texturelab" + # The inproc backend (used on macOS) does not upload in-handler like + # Crashpad — it persists the crash and delivers it on the NEXT launch. + # 1st run: crash, persisting the event to the sentry DB. + "$BIN" --sentry-crash-test || true + # 2nd run: sentry_init flushes the previous crash to Sentry; the built-in + # warm-up delay gives the transport time to upload before it crashes again. + # (This mirrors a real user reopening the app after a crash.) + "$BIN" --sentry-crash-test || true echo "waiting for the event to flush..." - sleep 25 + sleep 30 - name: Upload macOS artifact uses: actions/upload-artifact@v4 From f696500059b8d16dbd2bc81905411edf48006b75 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Thu, 16 Jul 2026 00:08:49 -0500 Subject: [PATCH 116/164] switch to using crashpad in mac builds --- .github/workflows/build.yml | 16 +++++----------- CMakeLists.txt | 5 ++++- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f2731b5b..38b11adb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -256,17 +256,11 @@ jobs: if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sentry_crash_test == 'true' }} run: | export QT_QPA_PLATFORM=offscreen - BIN="build/src/texturelab/texturelab.app/Contents/MacOS/texturelab" - # The inproc backend (used on macOS) does not upload in-handler like - # Crashpad — it persists the crash and delivers it on the NEXT launch. - # 1st run: crash, persisting the event to the sentry DB. - "$BIN" --sentry-crash-test || true - # 2nd run: sentry_init flushes the previous crash to Sentry; the built-in - # warm-up delay gives the transport time to upload before it crashes again. - # (This mirrors a real user reopening the app after a crash.) - "$BIN" --sentry-crash-test || true - echo "waiting for the event to flush..." - sleep 30 + # Crashpad (crashpad_handler bundled next to the binary) uploads the + # minidump out-of-process, symbolicated by the dSYM uploaded above. + build/src/texturelab/texturelab.app/Contents/MacOS/texturelab --sentry-crash-test || true + echo "waiting for crashpad to upload the minidump..." + sleep 25 - name: Upload macOS artifact uses: actions/upload-artifact@v4 diff --git a/CMakeLists.txt b/CMakeLists.txt index a5226d31..fd3d45a5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,7 +14,10 @@ if(WIN32) set(SENTRY_BACKEND "crashpad" CACHE STRING "" FORCE) set(SENTRY_TRANSPORT "winhttp" CACHE STRING "" FORCE) elseif(APPLE) - set(SENTRY_BACKEND "inproc" CACHE STRING "" FORCE) + # Crashpad: out-of-process minidumps with full thread stacks (symbolicated via + # the uploaded dSYM), same as Win/Linux. The inproc backend delivered crashes + # but with no stack frames on Apple Silicon, so reports were unsymbolicatable. + set(SENTRY_BACKEND "crashpad" CACHE STRING "" FORCE) set(SENTRY_TRANSPORT "curl" CACHE STRING "" FORCE) else() set(SENTRY_BACKEND "crashpad" CACHE STRING "" FORCE) From 01316a886cb95972bbc5088f565398816f254ba6 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Thu, 16 Jul 2026 00:33:03 -0500 Subject: [PATCH 117/164] cmake crashpad fix --- .github/workflows/build.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 38b11adb..c9b70b08 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -235,7 +235,15 @@ jobs: cache: true - name: Configure CMake - run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_OSX_ARCHITECTURES="x86_64;arm64" -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" + run: | + # Crashpad's CMake locates the Mach mig defs at + # ${CMAKE_OSX_SYSROOT}/usr/include/mach/exc.defs. If CMAKE_OSX_SYSROOT + # is empty it resolves to /usr/include/mach (absent on modern macOS) + # and configure fails. Point it explicitly at the selected SDK, which + # ships the defs (and AGL.framework). + SDKROOT=$(xcrun --show-sdk-path) + echo "Using SDK: $SDKROOT" + cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_OSX_ARCHITECTURES="x86_64;arm64" -DCMAKE_OSX_SYSROOT="$SDKROOT" -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" - name: Build run: cmake --build build --target texturelab --parallel $(sysctl -n hw.ncpu) From ca4051be93d58ad1f6ca979a66939b73a4612bf3 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Thu, 16 Jul 2026 01:04:16 -0500 Subject: [PATCH 118/164] bump patch --- .github/workflows/build.yml | 35 ++++++++++++----------------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c9b70b08..2195f625 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,7 +28,7 @@ jobs: - name: Install Qt6 uses: jurplel/install-qt-action@v4 with: - version: "6.7.0" + version: "6.7.3" modules: "qtshadertools" dir: "${{ github.workspace }}/Qt" cache: true @@ -90,9 +90,9 @@ jobs: APPIMAGE_EXTRACT_AND_RUN: 1 DEPLOY_STDCXX: 1 run: | - export QMAKE=${{ github.workspace }}/Qt/Qt/6.7.0/gcc_64/bin/qmake - export PATH=${{ github.workspace }}/Qt/Qt/6.7.0/gcc_64/bin:$PATH - export LD_LIBRARY_PATH=${{ github.workspace }}/Qt/Qt/6.7.0/gcc_64/lib:$LD_LIBRARY_PATH + export QMAKE=${{ github.workspace }}/Qt/Qt/6.7.3/gcc_64/bin/qmake + export PATH=${{ github.workspace }}/Qt/Qt/6.7.3/gcc_64/bin:$PATH + export LD_LIBRARY_PATH=${{ github.workspace }}/Qt/Qt/6.7.3/gcc_64/lib:$LD_LIBRARY_PATH # Create desktop file cat > texturelab.desktop << 'EOF' @@ -140,7 +140,7 @@ jobs: - name: Install Qt6 uses: jurplel/install-qt-action@v4 with: - version: "6.7.0" + version: "6.7.3" arch: "win64_msvc2019_64" modules: "qtshadertools" dir: "${{ github.workspace }}/Qt" @@ -174,7 +174,7 @@ jobs: run: | New-Item -ItemType Directory -Path deploy Copy-Item "build\src\texturelab\Release\texturelab.exe" deploy\ - & "${{ github.workspace }}\Qt\Qt\6.7.0\msvc2019_64\bin\windeployqt.exe" "deploy\texturelab.exe" --release --no-translations + & "${{ github.workspace }}\Qt\Qt\6.7.3\msvc2019_64\bin\windeployqt.exe" "deploy\texturelab.exe" --release --no-translations $handler = @( "build\src\texturelab\Release\crashpad_handler.exe", "build\src\texturelab\crashpad_handler.exe", @@ -203,12 +203,10 @@ jobs: path: deploy/ build-macos: - # Apple Silicon runner with good availability. Qt 6.7.0's macOS CMake config - # references AGL.framework, which was removed in the macOS 15 SDK (Xcode 16). - # We pin Xcode 15 (macOS 14 SDK, AGL still present) and build a universal - # x86_64+arm64 binary so it runs on both Intel and Apple Silicon Macs. - # (Drop the Xcode pin once Qt is bumped to >= 6.7.3, which removes the AGL ref.) - runs-on: macos-14 + # Qt 6.7.3+ dropped the AGL.framework reference that broke on the modern + # macOS SDK, so we no longer need to pin an older Xcode — macos-latest works. + # Universal x86_64+arm64 so it runs on both Intel and Apple Silicon Macs. + runs-on: macos-latest steps: - name: Checkout repository @@ -218,18 +216,10 @@ jobs: fetch-depth: 0 fetch-tags: true - - name: Select Xcode 15 (macOS 14 SDK still ships AGL.framework) - run: | - XC=$(ls -d /Applications/Xcode_15*.app 2>/dev/null | sort -V | tail -1) - if [ -z "$XC" ]; then echo "No Xcode 15.x found on runner"; ls -d /Applications/Xcode_*.app; exit 1; fi - echo "Using $XC" - sudo xcode-select -s "$XC/Contents/Developer" - xcodebuild -version - - name: Install Qt6 uses: jurplel/install-qt-action@v4 with: - version: "6.7.0" + version: "6.7.3" modules: "qtshadertools" dir: "${{ github.workspace }}/Qt" cache: true @@ -239,8 +229,7 @@ jobs: # Crashpad's CMake locates the Mach mig defs at # ${CMAKE_OSX_SYSROOT}/usr/include/mach/exc.defs. If CMAKE_OSX_SYSROOT # is empty it resolves to /usr/include/mach (absent on modern macOS) - # and configure fails. Point it explicitly at the selected SDK, which - # ships the defs (and AGL.framework). + # and configure fails. Point it explicitly at the SDK, which ships them. SDKROOT=$(xcrun --show-sdk-path) echo "Using SDK: $SDKROOT" cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_OSX_ARCHITECTURES="x86_64;arm64" -DCMAKE_OSX_SYSROOT="$SDKROOT" -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" From ca8e85e2efd95f57ae6c07c52da78ee63a5b8e72 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Thu, 16 Jul 2026 01:45:20 -0500 Subject: [PATCH 119/164] update macOS build to use Xcode 15 for compatibility with Qt 6.7.x --- .github/workflows/build.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2195f625..cfcb5d81 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -203,10 +203,10 @@ jobs: path: deploy/ build-macos: - # Qt 6.7.3+ dropped the AGL.framework reference that broke on the modern - # macOS SDK, so we no longer need to pin an older Xcode — macos-latest works. - # Universal x86_64+arm64 so it runs on both Intel and Apple Silicon Macs. - runs-on: macos-latest + # Qt 6.7.x still references AGL.framework, which modern macOS SDKs (Xcode 16+) + # removed — so we pin Xcode 15 (macOS 14 SDK, AGL present) on macos-14. Build + # universal x86_64+arm64 so it runs on both Intel and Apple Silicon Macs. + runs-on: macos-14 steps: - name: Checkout repository @@ -216,6 +216,14 @@ jobs: fetch-depth: 0 fetch-tags: true + - name: Select Xcode 15 (macOS 14 SDK still ships AGL.framework) + run: | + XC=$(ls -d /Applications/Xcode_15*.app 2>/dev/null | sort -V | tail -1) + if [ -z "$XC" ]; then echo "No Xcode 15.x found on runner"; ls -d /Applications/Xcode_*.app; exit 1; fi + echo "Using $XC" + sudo xcode-select -s "$XC/Contents/Developer" + xcodebuild -version + - name: Install Qt6 uses: jurplel/install-qt-action@v4 with: From b93cd52c28e9baeb506ba5bd33e71038bd08efa7 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 19 Jul 2026 12:24:04 -0500 Subject: [PATCH 120/164] replace qmap [] lookups with .value() --- src/nodegraph/graph/scene.cpp | 8 +++++--- src/texturelab/graphics/texturerenderer.cpp | 13 ++++++++++++- src/texturelab/models.cpp | 10 ++++++---- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/nodegraph/graph/scene.cpp b/src/nodegraph/graph/scene.cpp index 90c1dd41..e24380e2 100644 --- a/src/nodegraph/graph/scene.cpp +++ b/src/nodegraph/graph/scene.cpp @@ -141,7 +141,9 @@ ConnectionPtr Scene::connectNodes(NodePtr leftNode, QString leftOutputName, return connPtr; } -NodePtr Scene::getNodeById(QString id) { return nodes[id]; } +// .value() (not operator[]): a read-only lookup must never default-insert a +// null entry into the map, which paint/drag/label iteration would dereference. +NodePtr Scene::getNodeById(QString id) { return nodes.value(id); } void Scene::addFrame(FramePtr frame) { @@ -149,7 +151,7 @@ void Scene::addFrame(FramePtr frame) frames[frame->id()] = frame; } -FramePtr Scene::getFrameById(QString id) { return frames[id]; } +FramePtr Scene::getFrameById(QString id) { return frames.value(id); } void Scene::removeFrame(FramePtr frame) { @@ -165,7 +167,7 @@ void Scene::addComment(CommentPtr comment) comments[comment->id()] = comment; } -CommentPtr Scene::getCommentById(QString id) { return comments[id]; } +CommentPtr Scene::getCommentById(QString id) { return comments.value(id); } void Scene::removeComment(CommentPtr comment) { diff --git a/src/texturelab/graphics/texturerenderer.cpp b/src/texturelab/graphics/texturerenderer.cpp index 6da3865f..f2469517 100644 --- a/src/texturelab/graphics/texturerenderer.cpp +++ b/src/texturelab/graphics/texturerenderer.cpp @@ -337,6 +337,11 @@ void TextureRenderer::update() // check for nodes that need updating and update for (auto& node : project->nodes) { + // Defensive: a null entry should never reach the map now that lookups + // use .value() (Step 1), but guard the render loop regardless. + if (!node) + continue; + if (!node->isGraphicsResourcesInitialized()) { // create texture initializeNodeGraphicsResources(node); @@ -727,6 +732,9 @@ TextureNodePtr TextureRenderer::getNextUpdatableNode() const // non-dirty the this is a valid node for (auto node : project->nodes) { + if (!node) + continue; + if (!node->isDirty) continue; @@ -735,7 +743,10 @@ TextureNodePtr TextureRenderer::getNextUpdatableNode() const // we have a dirty node, check if all deps are clean auto deps = project->getNodeDependencies(node->id); for (auto dep : deps) { - if (dep->isDirty) { + // A null dep means an input connection references a node that no + // longer exists; treat it as not-yet-renderable rather than + // dereferencing it. + if (!dep || dep->isDirty) { hasCleanDeps = false; break; } diff --git a/src/texturelab/models.cpp b/src/texturelab/models.cpp index 36b30383..e0436efc 100644 --- a/src/texturelab/models.cpp +++ b/src/texturelab/models.cpp @@ -7,17 +7,19 @@ TextureNodePtr TextureProject::getNodeById(const QString& id) { - return nodes[id]; + // .value() (not operator[]): a read-only lookup must never default-insert a + // null entry into the map, which the renderer would later dereference. + return nodes.value(id); } ConnectionPtr TextureProject::getConnectionById(const QString& id) { - return connections[id]; + return connections.value(id); } QVector TextureProject::getNodeDependencies(const QString& id) { - auto node = nodes[id]; + auto node = nodes.value(id); QVector cons; for (auto con : connections) { @@ -32,7 +34,7 @@ QVector TextureProject::getNodeDependencies(const QString& id) QVector TextureProject::getNodeRightOfNode(const QString& id) { - auto node = nodes[id]; + auto node = nodes.value(id); QVector cons; for (auto con : connections) { From c51872b6ff6254391bfc4584bfe804bbeedcd715 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 19 Jul 2026 12:38:26 -0500 Subject: [PATCH 121/164] tidy up texture channels on node deletion --- src/texturelab/undo/deleteitemscommand.cpp | 21 ++ src/texturelab/undo/deleteitemscommand.h | 4 + src/texturelab/widgets/graphwidget.cpp | 228 +++++++++++++-------- 3 files changed, 162 insertions(+), 91 deletions(-) diff --git a/src/texturelab/undo/deleteitemscommand.cpp b/src/texturelab/undo/deleteitemscommand.cpp index 8b3109b6..8ab68c8b 100644 --- a/src/texturelab/undo/deleteitemscommand.cpp +++ b/src/texturelab/undo/deleteitemscommand.cpp @@ -35,6 +35,15 @@ DeleteItemsCommand::DeleteItemsCommand(TextureProjectPtr project, QSet deletedNodeIds(nodeIds.begin(), nodeIds.end()); + // Capture any texture-channel assignments pointing at a deleted node so we + // can remove them in redo() and restore them in undo(). Left behind, a + // stale id here is later looked up by passTextureChannelsToViewer3D(). + for (auto it = _project->textureChannels.constBegin(); + it != _project->textureChannels.constEnd(); ++it) { + if (deletedNodeIds.contains(it.value())) + _channelAssignments.insert(it.key(), it.value()); + } + for (const auto& id : nodeIds) { auto node = _project->getNodeById(id); if (!node) @@ -129,6 +138,12 @@ void DeleteItemsCommand::redo() _project->comments.remove(sc.id); } + // Drop channel assignments that referenced the now-deleted nodes. + for (auto it = _channelAssignments.constBegin(); + it != _channelAssignments.constEnd(); ++it) { + _project->textureChannels.remove(it.key()); + } + if (_renderer) _renderer->update(); } @@ -197,6 +212,12 @@ void DeleteItemsCommand::undo() _scene->addComment(gcomment); } + // Restore channel assignments now that the nodes they reference exist again. + for (auto it = _channelAssignments.constBegin(); + it != _channelAssignments.constEnd(); ++it) { + _project->textureChannels.insert(it.key(), it.value()); + } + if (_renderer) _renderer->update(); } diff --git a/src/texturelab/undo/deleteitemscommand.h b/src/texturelab/undo/deleteitemscommand.h index f44f82c5..5f8eae77 100644 --- a/src/texturelab/undo/deleteitemscommand.h +++ b/src/texturelab/undo/deleteitemscommand.h @@ -56,4 +56,8 @@ class DeleteItemsCommand : public QUndoCommand { QList _connections; QList _frames; QList _comments; + + // Texture-channel (Albedo/Normal/…) assignments that pointed at a deleted + // node; captured so redo() can drop them and undo() can restore them. + QMap _channelAssignments; }; diff --git a/src/texturelab/widgets/graphwidget.cpp b/src/texturelab/widgets/graphwidget.cpp index c7a3e784..ae992647 100644 --- a/src/texturelab/widgets/graphwidget.cpp +++ b/src/texturelab/widgets/graphwidget.cpp @@ -1,7 +1,6 @@ #include "graphwidget.h" #include "../clipboard.h" #include "../undo/undocommands.h" -#include #include #include #include @@ -15,6 +14,7 @@ #include #include #include +#include class NoWheelComboBox : public QComboBox { public: @@ -73,18 +73,18 @@ GraphWidget::GraphWidget() : QMainWindow(nullptr) connect(graph, &nodegraph::NodeGraph::connectionAdded, [=](nodegraph::ConnectionPtr con) { - auto leftNodeId = con->startPort->node->id(); - auto leftOutput = con->startPort->name; + auto leftNodeId = con->startPort->node->id(); + auto leftOutput = con->startPort->name; auto rightNodeId = con->endPort->node->id(); - auto rightInput = con->endPort->name; + auto rightInput = con->endPort->name; if (undoStack) undoStack->push(new AddConnectionCommand( - project, scene, renderer, - leftNodeId, leftOutput, rightNodeId, rightInput)); + project, scene, renderer, leftNodeId, leftOutput, + rightNodeId, rightInput)); else { - project->addConnection( - project->getNodeById(leftNodeId), - project->getNodeById(rightNodeId), rightInput); + project->addConnection(project->getNodeById(leftNodeId), + project->getNodeById(rightNodeId), + rightInput); project->getNodeById(rightNodeId)->isDirty = true; renderer->update(); } @@ -92,14 +92,14 @@ GraphWidget::GraphWidget() : QMainWindow(nullptr) connect(graph, &nodegraph::NodeGraph::connectionRemoved, [=](nodegraph::ConnectionPtr con) { - auto leftNodeId = con->startPort->node->id(); - auto leftOutput = con->startPort->name; + auto leftNodeId = con->startPort->node->id(); + auto leftOutput = con->startPort->name; auto rightNodeId = con->endPort->node->id(); - auto rightInput = con->endPort->name; + auto rightInput = con->endPort->name; if (undoStack) undoStack->push(new RemoveConnectionCommand( - project, scene, renderer, - leftNodeId, leftOutput, rightNodeId, rightInput)); + project, scene, renderer, leftNodeId, leftOutput, + rightNodeId, rightInput)); else { auto con2 = project->removeConnection( leftNodeId, rightNodeId, rightInput); @@ -141,44 +141,63 @@ GraphWidget::GraphWidget() : QMainWindow(nullptr) } }); - connect(graph, &nodegraph::NodeGraph::deleteRequested, - [=](QList nodes, - QList frames, - QList comments) { - QList nodeIds, frameIds, commentIds; - for (auto& n : nodes) nodeIds.append(n->id()); - for (auto& f : frames) frameIds.append(f->id()); - for (auto& c : comments) commentIds.append(c->id()); - if (undoStack) - undoStack->push(new DeleteItemsCommand( - project, scene, renderer, nodeIds, frameIds, commentIds)); - else { - // Fallback: direct deletion (no undo) - for (auto& n : nodes) { - scene->removeNode(n); - for (auto key : project->connections.keys()) { - auto con = project->connections.value(key); - if (con->leftNode->id == n->id() || con->rightNode->id == n->id()) { - if (con->leftNode->id == n->id()) - con->rightNode->isDirty = true; - project->connections.remove(key); - } + connect( + graph, &nodegraph::NodeGraph::deleteRequested, + [=](QList nodes, QList frames, + QList comments) { + QList nodeIds, frameIds, commentIds; + for (auto& n : nodes) + nodeIds.append(n->id()); + for (auto& f : frames) + frameIds.append(f->id()); + for (auto& c : comments) + commentIds.append(c->id()); + if (undoStack) + undoStack->push(new DeleteItemsCommand( + project, scene, renderer, nodeIds, frameIds, commentIds)); + else { + // Fallback: direct deletion (no undo) + for (auto& n : nodes) { + scene->removeNode(n); + + // todo: move this into project class + for (auto key : project->connections.keys()) { + auto con = project->connections.value(key); + if (con->leftNode->id == n->id() || + con->rightNode->id == n->id()) { + if (con->leftNode->id == n->id()) + con->rightNode->isDirty = true; + project->connections.remove(key); } - project->nodes.remove(n->id()); } - for (auto& f : frames) { scene->removeFrame(f); project->frames.remove(f->id()); } - for (auto& c : comments) { scene->removeComment(c); project->comments.remove(c->id()); } - renderer->update(); + // Drop any texture-channel assignment for this node so + // its stale id can't be looked up after deletion. + for (auto ch : project->textureChannels.keys()) { + if (project->textureChannels.value(ch) == n->id()) + project->textureChannels.remove(ch); + } + project->nodes.remove(n->id()); } - emit nodeSelectionChanged(TextureNodePtr(nullptr)); - emit frameSelectionChanged(FramePtr(nullptr)); - emit commentSelectionChanged(CommentPtr(nullptr)); - }); + for (auto& f : frames) { + scene->removeFrame(f); + project->frames.remove(f->id()); + } + for (auto& c : comments) { + scene->removeComment(c); + project->comments.remove(c->id()); + } + renderer->update(); + } + emit nodeSelectionChanged(TextureNodePtr(nullptr)); + emit frameSelectionChanged(FramePtr(nullptr)); + emit commentSelectionChanged(CommentPtr(nullptr)); + }); connect(graph, &nodegraph::NodeGraph::itemsMoveFinished, [=](QMap oldPos, QMap newPos) { if (undoStack) - undoStack->push(new MoveItemsCommand(project, scene, oldPos, newPos)); + undoStack->push( + new MoveItemsCommand(project, scene, oldPos, newPos)); }); connect(graph, &nodegraph::NodeGraph::frameSelectionChanged, @@ -205,7 +224,8 @@ GraphWidget::GraphWidget() : QMainWindow(nullptr) auto copyShortcut = new QShortcut(QKeySequence::Copy, this); copyShortcut->setContext(Qt::WidgetWithChildrenShortcut); - connect(copyShortcut, &QShortcut::activated, this, &GraphWidget::executeCopy); + connect(copyShortcut, &QShortcut::activated, this, + &GraphWidget::executeCopy); auto cutShortcut = new QShortcut(QKeySequence::Cut, this); cutShortcut->setContext(Qt::WidgetWithChildrenShortcut); @@ -213,7 +233,8 @@ GraphWidget::GraphWidget() : QMainWindow(nullptr) auto pasteShortcut = new QShortcut(QKeySequence::Paste, this); pasteShortcut->setContext(Qt::WidgetWithChildrenShortcut); - connect(pasteShortcut, &QShortcut::activated, this, &GraphWidget::executePaste); + connect(pasteShortcut, &QShortcut::activated, this, + &GraphWidget::executePaste); } void GraphWidget::setupToolbar() @@ -405,34 +426,36 @@ void GraphWidget::dropEvent(QDropEvent* evt) auto scenePos = this->graph->mapToScene(evt->position().toPoint()); if (data->itemType == PopupItemType::Frame) { - QString frameId = QUuid::createUuid().toString(QUuid::WithoutBraces); + QString frameId = + QUuid::createUuid().toString(QUuid::WithoutBraces); if (undoStack) - undoStack->push(new AddFrameCommand( - project, scene, frameId, QVector2D(scenePos))); + undoStack->push(new AddFrameCommand(project, scene, frameId, + QVector2D(scenePos))); else { auto frame = nodegraph::Frame::create(); frame->setPos(scenePos); scene->addFrame(frame); if (project) { auto modelFrame = FramePtr(new Frame()); - modelFrame->id = frame->id(); + modelFrame->id = frame->id(); modelFrame->pos = QVector2D(scenePos); project->frames[modelFrame->id] = modelFrame; } } } else if (data->itemType == PopupItemType::Comment) { - QString commentId = QUuid::createUuid().toString(QUuid::WithoutBraces); + QString commentId = + QUuid::createUuid().toString(QUuid::WithoutBraces); if (undoStack) - undoStack->push(new AddCommentCommand( - project, scene, commentId, QVector2D(scenePos))); + undoStack->push(new AddCommentCommand(project, scene, commentId, + QVector2D(scenePos))); else { auto comment = nodegraph::Comment::create(); comment->setPos(scenePos); scene->addComment(comment); if (project) { auto modelComment = CommentPtr(new Comment()); - modelComment->id = comment->id(); + modelComment->id = comment->id(); modelComment->pos = QVector2D(scenePos); project->comments[modelComment->id] = modelComment; } @@ -440,9 +463,9 @@ void GraphWidget::dropEvent(QDropEvent* evt) } else { if (undoStack) - undoStack->push(new AddNodeCommand( - project, scene, renderer, - data->libraryItemName, QVector2D(scenePos))); + undoStack->push(new AddNodeCommand(project, scene, renderer, + data->libraryItemName, + QVector2D(scenePos))); else { auto node = project->library->createNode(data->libraryItemName); node->pos = QVector2D(scenePos); @@ -530,15 +553,18 @@ void GraphWidget::executeCut() for (auto item : scene->selectedItems()) { if (item->type() == (int)nodegraph::SceneItemType::Node) { auto node = qgraphicsitem_cast(item); - if (node) nodeIds.append(node->id()); + if (node) + nodeIds.append(node->id()); } else if (item->type() == (int)nodegraph::SceneItemType::Frame) { auto frame = qgraphicsitem_cast(item); - if (frame) frameIds.append(frame->id()); + if (frame) + frameIds.append(frame->id()); } else if (item->type() == (int)nodegraph::SceneItemType::Comment) { auto comment = qgraphicsitem_cast(item); - if (comment) commentIds.append(comment->id()); + if (comment) + commentIds.append(comment->id()); } } @@ -546,30 +572,40 @@ void GraphWidget::executeCut() return; if (undoStack) - undoStack->push(new DeleteItemsCommand( - project, scene, renderer, nodeIds, frameIds, commentIds)); + undoStack->push(new DeleteItemsCommand(project, scene, renderer, + nodeIds, frameIds, commentIds)); else { for (const auto& id : nodeIds) { auto ngNode = scene->getNodeById(id); - if (ngNode) scene->removeNode(ngNode); + if (ngNode) + scene->removeNode(ngNode); for (auto key : project->connections.keys()) { auto con = project->connections.value(key); if (con->leftNode->id == id || con->rightNode->id == id) project->connections.remove(key); } + // Drop any texture-channel assignment for this node so its stale id + // can't be looked up after deletion. + for (auto ch : project->textureChannels.keys()) { + if (project->textureChannels.value(ch) == id) + project->textureChannels.remove(ch); + } project->nodes.remove(id); } for (const auto& id : frameIds) { auto f = scene->getFrameById(id); - if (f) scene->removeFrame(f); + if (f) + scene->removeFrame(f); project->frames.remove(id); } for (const auto& id : commentIds) { auto c = scene->getCommentById(id); - if (c) scene->removeComment(c); + if (c) + scene->removeComment(c); project->comments.remove(id); } - if (renderer) renderer->update(); + if (renderer) + renderer->update(); } emit nodeSelectionChanged(TextureNodePtr(nullptr)); @@ -586,16 +622,20 @@ void GraphWidget::executePaste() if (undoStack) { auto* cmd = new PasteCommand(project, scene, renderer, viewCenter); - if (cmd->isEmpty()) { delete cmd; return; } + if (cmd->isEmpty()) { + delete cmd; + return; + } undoStack->push(cmd); - } else { + } + else { QList newNodes; QList newConnections; QList newComments; QList newFrames; - if (!Clipboard::pasteItems(project, viewCenter, newNodes, newConnections, - newComments, newFrames)) + if (!Clipboard::pasteItems(project, viewCenter, newNodes, + newConnections, newComments, newFrames)) return; scene->clearSelection(); @@ -603,39 +643,44 @@ void GraphWidget::executePaste() project->nodes[node->id] = node; addNode(node); auto ngNode = scene->getNodeById(node->id); - if (ngNode) ngNode->setSelected(true); + if (ngNode) + ngNode->setSelected(true); } for (auto& con : newConnections) { project->connections[con->id] = con; con->rightNode->isDirty = true; auto l = scene->getNodeById(con->leftNode->id); auto r = scene->getNodeById(con->rightNode->id); - if (l && r) scene->connectNodes(l, "output", r, con->rightNodeInputName); + if (l && r) + scene->connectNodes(l, "output", r, con->rightNodeInputName); } for (auto& comment : newComments) { project->comments[comment->id] = comment; auto gc = nodegraph::Comment::create(); - gc->setId(comment->id); gc->setText(comment->text); + gc->setId(comment->id); + gc->setText(comment->text); gc->setPos(comment->pos.x(), comment->pos.y()); - scene->addComment(gc); gc->setSelected(true); + scene->addComment(gc); + gc->setSelected(true); } for (auto& frame : newFrames) { project->frames[frame->id] = frame; auto gf = nodegraph::Frame::create(); - gf->setId(frame->id); gf->setTitle(frame->text); gf->setColor(frame->color); + gf->setId(frame->id); + gf->setTitle(frame->text); + gf->setColor(frame->color); gf->setPos(frame->pos.x(), frame->pos.y()); if (frame->size.x() > 0 && frame->size.y() > 0) gf->setSize(frame->size.x(), frame->size.y()); - scene->addFrame(gf); gf->setSelected(true); + scene->addFrame(gf); + gf->setSelected(true); } - if (renderer) renderer->update(); + if (renderer) + renderer->update(); } } -void GraphWidget::setUndoStack(QUndoStack* stack) -{ - undoStack = stack; -} +void GraphWidget::setUndoStack(QUndoStack* stack) { undoStack = stack; } void GraphWidget::addItemFromSearch(const QString& name, PopupItemType type, const QPoint& position) @@ -646,15 +691,15 @@ void GraphWidget::addItemFromSearch(const QString& name, PopupItemType type, if (type == PopupItemType::Frame) { QString frameId = QUuid::createUuid().toString(QUuid::WithoutBraces); if (undoStack) - undoStack->push(new AddFrameCommand( - project, scene, frameId, QVector2D(scenePos))); + undoStack->push(new AddFrameCommand(project, scene, frameId, + QVector2D(scenePos))); else { auto frame = nodegraph::Frame::create(); frame->setPos(scenePos); scene->addFrame(frame); if (project) { auto modelFrame = FramePtr(new Frame()); - modelFrame->id = frame->id(); + modelFrame->id = frame->id(); modelFrame->pos = QVector2D(scenePos); project->frames[modelFrame->id] = modelFrame; } @@ -663,15 +708,15 @@ void GraphWidget::addItemFromSearch(const QString& name, PopupItemType type, else if (type == PopupItemType::Comment) { QString commentId = QUuid::createUuid().toString(QUuid::WithoutBraces); if (undoStack) - undoStack->push(new AddCommentCommand( - project, scene, commentId, QVector2D(scenePos))); + undoStack->push(new AddCommentCommand(project, scene, commentId, + QVector2D(scenePos))); else { auto comment = nodegraph::Comment::create(); comment->setPos(scenePos); scene->addComment(comment); if (project) { auto modelComment = CommentPtr(new Comment()); - modelComment->id = comment->id(); + modelComment->id = comment->id(); modelComment->pos = QVector2D(scenePos); project->comments[modelComment->id] = modelComment; } @@ -682,14 +727,15 @@ void GraphWidget::addItemFromSearch(const QString& name, PopupItemType type, return; if (undoStack) - undoStack->push(new AddNodeCommand( - project, scene, renderer, name, QVector2D(scenePos))); + undoStack->push(new AddNodeCommand(project, scene, renderer, name, + QVector2D(scenePos))); else { auto node = project->library->createNode(name); node->pos = QVector2D(scenePos); project->addNode(node); addNode(node); - if (renderer) renderer->update(); + if (renderer) + renderer->update(); } } } \ No newline at end of file From 3cee8481db8905c08edfeeb170e5f51809d11006 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 19 Jul 2026 12:52:10 -0500 Subject: [PATCH 122/164] fix cases of missing nodes causing null pointer dereferencing --- src/texturelab/mainwindow.cpp | 6 ++++++ src/texturelab/models.cpp | 10 +++++++++- src/texturelab/project.cpp | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index ea0fb1cc..57d20a02 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -227,6 +227,12 @@ void MainWindow::passTextureChannelsToViewer3D() if (!node) continue; + // Skip nodes whose FBO/shader aren't ready yet: textureId() would + // otherwise dereference a null texture. They'll be picked up on the + // next sync once the render worker has produced their texture. + if (!node->isGraphicsResourcesInitialized()) + continue; + switch (channel) { case TextureChannel::Albedo: viewer->setAlbedoTexture(node->textureId()); diff --git a/src/texturelab/models.cpp b/src/texturelab/models.cpp index e0436efc..c8b9f412 100644 --- a/src/texturelab/models.cpp +++ b/src/texturelab/models.cpp @@ -77,6 +77,9 @@ ConnectionPtr TextureProject::removeConnection(const QString& leftNode, for (auto conKey : connections.keys()) { auto con = connections[conKey]; + if (!con || !con->leftNode || !con->rightNode) + continue; + if (con->leftNode->id == leftNode && con->rightNode->id == rightNode && con->rightNodeInputName == rightNodeInput) { connections.remove(conKey); @@ -155,7 +158,12 @@ Prop* TextureNode::getProp(QString propName) bool TextureNode::hasProp(QString propName) { return props.contains(propName); } -unsigned int TextureNode::textureId() { return this->texture->texture(); } +unsigned int TextureNode::textureId() +{ + // texture is null until the node's graphics resources are initialized; + // return 0 (the GL "no texture" name) rather than dereferencing null. + return this->texture ? this->texture->texture() : 0; +} PropertyGroup* TextureNode::createGroup(const QString& name) { diff --git a/src/texturelab/project.cpp b/src/texturelab/project.cpp index 27c7230f..752797dd 100644 --- a/src/texturelab/project.cpp +++ b/src/texturelab/project.cpp @@ -48,6 +48,10 @@ TextureProjectPtr Project::loadTextureFromJson(QJsonObject json) auto nodeDef = item.toObject(); auto nodeName = nodeDef["typeName"].toString(); auto node = lib->createNode(nodeName); + // createNode returns null for an unknown/legacy typeName the resolved + // library can't build; skip it rather than dereferencing null. + if (!node) + continue; node->exportName = nodeDef["exportName"].toString(""); node->id = nodeDef["id"].toString(); node->randomSeed = (long)nodeDef["randomSeed"].toDouble(0); @@ -89,6 +93,12 @@ TextureProjectPtr Project::loadTextureFromJson(QJsonObject json) QString rightNodeId = conObj["rightNodeId"].toString(); TextureNodePtr rightNode = texture->getNodeById(rightNodeId); + // Drop connections whose endpoints didn't load (unknown/legacy node + // that was skipped, or a hand-edited/migrated file). Storing a + // connection with a null endpoint would crash save/remove later. + if (!leftNode || !rightNode) + continue; + QString rightNodeInputId = conObj["rightNodeInput"].toString(); texture->addConnection(leftNode, rightNode, rightNodeInputId); } @@ -230,6 +240,10 @@ QByteArray Project::saveTexture(TextureProjectPtr texture) // connections QJsonArray conArray; for (auto& con : texture->connections) { + // Never serialize a connection with a missing endpoint (would crash on + // the deref below and produce an unloadable file). + if (!con || !con->leftNode || !con->rightNode) + continue; QJsonObject conObj; conObj["leftNodeId"] = con->leftNode->id; conObj["rightNodeId"] = con->rightNode->id; From 7234de15764d42afe5e249a56b9bf218031d8ad2 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 19 Jul 2026 12:56:20 -0500 Subject: [PATCH 123/164] fix potential crash caused by missing node render data --- src/texturelab/graphics/renderworker.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/texturelab/graphics/renderworker.cpp b/src/texturelab/graphics/renderworker.cpp index ac7bf3eb..f0113983 100644 --- a/src/texturelab/graphics/renderworker.cpp +++ b/src/texturelab/graphics/renderworker.cpp @@ -236,8 +236,11 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) ctx->makeCurrent(surface); - // Custom renderer path — node defines its own multi-pass rendering - if (command.renderer) { + // Custom renderer path — node defines its own multi-pass rendering. + // Require renderData too: the renderer immediately downcasts and reads + // fields off it, so a null (a node that overrides createRenderer() but not + // createRenderData()) would form a null reference and crash here. + if (command.renderer && command.renderData) { NodeRenderContext renderCtx; renderCtx.gl = gl; renderCtx.cache = &resourceCache; From 3f4089e7609d8a7217835ac6f976c970e68955a5 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 19 Jul 2026 13:09:49 -0500 Subject: [PATCH 124/164] fix potential memory issues due to incorrect texture size --- src/texturelab/exporter.cpp | 10 ++++++++-- src/texturelab/graphics/texturerenderer.cpp | 2 +- src/texturelab/libraries/v2/bevel.cpp | 20 ++++++++++++-------- src/texturelab/libraries/v2/floodfill.cpp | 8 +++++++- src/texturelab/libraries/v3/floodfillv2.cpp | 8 +++++++- 5 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/texturelab/exporter.cpp b/src/texturelab/exporter.cpp index ebe3fb2d..bbd74458 100644 --- a/src/texturelab/exporter.cpp +++ b/src/texturelab/exporter.cpp @@ -23,12 +23,18 @@ ExportResult Exporter::exportTexture(QOpenGLFramebufferObject* texture, int width = texture->width(); int height = texture->height(); + if (width <= 0 || height <= 0) { + result.errorMessage = "Invalid texture dimensions for export"; + return result; + } + // Bind the FBO and read pixels texture->bind(); QOpenGLFunctions* gl = QOpenGLContext::currentContext()->functions(); - // Read as float data (since texture is GL_RGBA32F) - std::vector floatData(width * height * 4); + // Read as float data (since texture is GL_RGBA32F). + // (size_t) so width*height*4 can't overflow int for large exports. + std::vector floatData((size_t)width * height * 4); gl->glReadPixels(0, 0, width, height, GL_RGBA, GL_FLOAT, floatData.data()); texture->release(); diff --git a/src/texturelab/graphics/texturerenderer.cpp b/src/texturelab/graphics/texturerenderer.cpp index f2469517..3f0f7268 100644 --- a/src/texturelab/graphics/texturerenderer.cpp +++ b/src/texturelab/graphics/texturerenderer.cpp @@ -448,7 +448,7 @@ void TextureRenderer::createNodeTexture(const TextureNodePtr& node) QOpenGLFramebufferObjectFormat fboFormat; fboFormat.setInternalTextureFormat(GL_RGBA32F); node->texture = new QOpenGLFramebufferObject( - project->textureWidth, project->textureWidth, fboFormat); + project->textureWidth, project->textureHeight, fboFormat); node->textureWidth = project->textureWidth; node->textureHeight = project->textureHeight; diff --git a/src/texturelab/libraries/v2/bevel.cpp b/src/texturelab/libraries/v2/bevel.cpp index e3874940..ea84d5db 100644 --- a/src/texturelab/libraries/v2/bevel.cpp +++ b/src/texturelab/libraries/v2/bevel.cpp @@ -15,11 +15,11 @@ static const float VALUE_MAX = 1.0f; // Forward declarations for EDT functions static void edt(std::vector& data, int width, int height, - std::vector& f, std::vector& v, + std::vector& f, std::vector& v, std::vector& z); static void edt1d(std::vector& grid, int offset, int stride, int length, - std::vector& f, std::vector& v, + std::vector& f, std::vector& v, std::vector& z); void BevelNode::init() @@ -60,10 +60,14 @@ void BevelNode::cpuProcess(void* glPtr, const RenderCommand& command) int width = command.textureWidth; int height = command.textureHeight; - // Allocate buffers + // Guard against zero/negative dimensions before any allocation or indexing. + if (width <= 0 || height <= 0) + return; + + // Allocate buffers ((size_t) casts so the *4 can't overflow int). int gridSize = width * height; - std::vector readPixels(gridSize * 4); - std::vector resultPixels(gridSize * 4); + std::vector readPixels((size_t)gridSize * 4); + std::vector resultPixels((size_t)gridSize * 4); // Read pixels from input texture GLuint fbo; @@ -85,7 +89,7 @@ void BevelNode::cpuProcess(void* glPtr, const RenderCommand& command) int maxSize = std::max(width, height); std::vector f(maxSize * 3); std::vector z(maxSize * 3 + 1); - std::vector v(maxSize * 3); + std::vector v(maxSize * 3); std::vector gridOuter(gridSize); std::vector gridInner(gridSize); @@ -158,7 +162,7 @@ void BevelNode::cpuProcess(void* glPtr, const RenderCommand& command) // 2D Euclidean squared distance transform by Felzenszwalb & Huttenlocher // https://cs.brown.edu/~pff/papers/dt-final.pdf static void edt(std::vector& data, int width, int height, - std::vector& f, std::vector& v, + std::vector& f, std::vector& v, std::vector& z) { for (int x = 0; x < width; x++) @@ -169,7 +173,7 @@ static void edt(std::vector& data, int width, int height, // 1D squared distance transform static void edt1d(std::vector& grid, int offset, int stride, int length, - std::vector& f, std::vector& v, + std::vector& f, std::vector& v, std::vector& z) { v[0] = 0; diff --git a/src/texturelab/libraries/v2/floodfill.cpp b/src/texturelab/libraries/v2/floodfill.cpp index b2a2f6a0..78b5bb7d 100644 --- a/src/texturelab/libraries/v2/floodfill.cpp +++ b/src/texturelab/libraries/v2/floodfill.cpp @@ -111,7 +111,13 @@ void FloodFillNode::cpuProcess(void* glPtr, const RenderCommand& command) int width = command.textureWidth; int height = command.textureHeight; - int gridSize = width * height; + // Guard against zero/negative dimensions: wrapAround() below does value % + // width/height (division by zero), and a negative product would wrap to a + // huge size_t at allocation. + if (width <= 0 || height <= 0) + return; + // size_t so width*height*4 can't overflow int for large textures. + size_t gridSize = (size_t)width * height; // Read pixels from input texture std::vector readPixels(gridSize * 4); diff --git a/src/texturelab/libraries/v3/floodfillv2.cpp b/src/texturelab/libraries/v3/floodfillv2.cpp index e7313644..926d27af 100644 --- a/src/texturelab/libraries/v3/floodfillv2.cpp +++ b/src/texturelab/libraries/v3/floodfillv2.cpp @@ -45,6 +45,11 @@ class FloodFillV2Renderer : public NodeTextureRenderer { int w = ctx.textureWidth; int h = ctx.textureHeight; + // Guard against zero/negative dimensions: wrapAround() below divides by + // w/h, and a negative product would wrap to a huge size_t allocation. + if (w <= 0 || h <= 0) + return; + // No input — output black if (ctx.inputs.isEmpty() || ctx.inputs[0].textureId == 0) { cache->bindFboToTexture(ctx.outputTextureId); @@ -54,7 +59,8 @@ class FloodFillV2Renderer : public NodeTextureRenderer { return; } - int gridSize = w * h; + // size_t so w*h*4 can't overflow int for large textures. + size_t gridSize = (size_t)w * h; // Read input pixels via FBO std::vector readPixels(gridSize * 4); From f2030ef7e94e87f2bd505f8b4694a244671dda05 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 19 Jul 2026 13:27:45 -0500 Subject: [PATCH 125/164] properly free mesh in destructor --- src/viewer3d/renderer/renderer.cpp | 19 ++++++++++++ src/viewer3d/renderer/renderer.h | 8 +++++ src/viewer3d/viewer3d.cpp | 49 +++++++++++++++--------------- 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/src/viewer3d/renderer/renderer.cpp b/src/viewer3d/renderer/renderer.cpp index 523e14ec..df0a5338 100644 --- a/src/viewer3d/renderer/renderer.cpp +++ b/src/viewer3d/renderer/renderer.cpp @@ -28,6 +28,22 @@ class MeshPrivate { tinygltf::Accessor indexAccessor; }; +Mesh::~Mesh() +{ + // For glTF meshes, indexBuffer aliases one of the vbos entries (see + // loadMeshFromRc), so delete it only if it isn't already owned by vbos. + bool indexAliased = false; + for (auto& kv : vbos) { + if (kv.second == indexBuffer) + indexAliased = true; + delete kv.second; + } + if (indexBuffer && !indexAliased) + delete indexBuffer; + delete vao; + // material is not owned by the mesh (shared, owned by Viewer3D) — not freed. +} + void Renderer::init(QOpenGLFunctions* gl) { this->gl = gl; @@ -260,6 +276,9 @@ void Renderer::renderGltfMesh(Mesh* mesh, Material* material, const QMatrix4x4& viewMatrix, const QMatrix4x4& projMatrix) { + if (!mesh || !material) + return; + // setup material auto mat = material; if (mat->needsUpdate) { diff --git a/src/viewer3d/renderer/renderer.h b/src/viewer3d/renderer/renderer.h index ebb5b68d..b9955f91 100644 --- a/src/viewer3d/renderer/renderer.h +++ b/src/viewer3d/renderer/renderer.h @@ -35,6 +35,14 @@ enum class MeshType { Generated, Gltf }; class MeshPrivate; class Mesh { public: + Mesh() = default; + // Frees the owned GL objects (vao, vbos, indexBuffer). Defined in + // renderer.cpp where those types are complete. + ~Mesh(); + // Owns raw GL pointers; non-copyable to avoid double-free. + Mesh(const Mesh&) = delete; + Mesh& operator=(const Mesh&) = delete; + QOpenGLVertexArrayObject* vao = nullptr; std::map vbos; QList attribs; diff --git a/src/viewer3d/viewer3d.cpp b/src/viewer3d/viewer3d.cpp index 804cb287..485ee759 100644 --- a/src/viewer3d/viewer3d.cpp +++ b/src/viewer3d/viewer3d.cpp @@ -148,13 +148,15 @@ void Viewer3D::paintGL() gl->glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); gl->glEnable(GL_CULL_FACE); - gl->glCullFace(GL_FRONT); - renderer->renderGltfMesh(gltfMesh, material, camPos, worldMatrix, - viewMatrix, projMatrix); + if (gltfMesh) { + gl->glCullFace(GL_FRONT); + renderer->renderGltfMesh(gltfMesh, material, camPos, worldMatrix, + viewMatrix, projMatrix); - gl->glCullFace(GL_BACK); - renderer->renderGltfMesh(gltfMesh, material, camPos, worldMatrix, - viewMatrix, projMatrix); + gl->glCullFace(GL_BACK); + renderer->renderGltfMesh(gltfMesh, material, camPos, worldMatrix, + viewMatrix, projMatrix); + } gl->glDisable(GL_CULL_FACE); gl->glDisable(GL_BLEND); @@ -606,46 +608,45 @@ void Viewer3D::setModel(const QString& modelType) // Bind OpenGL context makeCurrent(); - // Clean up old mesh - if (gltfMesh) { - delete gltfMesh; - gltfMesh = nullptr; - } - - // Create new mesh based on type + // Build the new mesh into a local first; only swap in (and free the old + // one) once creation succeeds, so a failed allocation can't leave gltfMesh + // null or delete the current mesh prematurely. + Mesh* newMesh = nullptr; if (modelType == "sphere") { - gltfMesh = createSphere(this->gl, 2, 1000, 1000); + newMesh = createSphere(this->gl, 2, 1000, 1000); } else if (modelType == "plane_xy") { // Create a subdivided plane in XY orientation - gltfMesh = - createPlane(this->gl, 4, 4, 1000, 1000, PlaneOrientation::XY); + newMesh = createPlane(this->gl, 4, 4, 1000, 1000, PlaneOrientation::XY); } else if (modelType == "plane_yz") { // Create a subdivided plane in YZ orientation - gltfMesh = - createPlane(this->gl, 4, 4, 1000, 1000, PlaneOrientation::YZ); + newMesh = createPlane(this->gl, 4, 4, 1000, 1000, PlaneOrientation::YZ); } else if (modelType == "plane_xz") { // Create a subdivided plane in XZ orientation - gltfMesh = - createPlane(this->gl, 4, 4, 1000, 1000, PlaneOrientation::XZ); + newMesh = createPlane(this->gl, 4, 4, 1000, 1000, PlaneOrientation::XZ); } else if (modelType == "cylinder") { // Create a cylinder with height subdivisions for displacement mapping - gltfMesh = createCylinder(this->gl, 1, 1, 2, 1000, 1000, 0.1f, 16); + newMesh = createCylinder(this->gl, 1, 1, 2, 1000, 1000, 0.1f, 16); } else if (modelType == "cube") { // Create a subdivided cube - gltfMesh = createCube(this->gl, 2, 2, 2, 1000, 1000, 1000); + newMesh = createCube(this->gl, 2, 2, 2, 1000, 1000, 1000); } else if (modelType == "cubesphere") { // CubeSphere - a sphere with low segments for a more cubic look - gltfMesh = createSphere(this->gl, 2, 8, 8); + newMesh = createSphere(this->gl, 2, 8, 8); } else { // Default to sphere - gltfMesh = createSphere(this->gl, 2, 1000, 1000); + newMesh = createSphere(this->gl, 2, 1000, 1000); + } + + if (newMesh) { + delete gltfMesh; + gltfMesh = newMesh; } // Release OpenGL context From 32aea7dc09117fae35df67cb8f09d0951c4a634c Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 19 Jul 2026 13:32:34 -0500 Subject: [PATCH 126/164] replace asserts with null pointers --- src/nodegraph/graph/scene.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/nodegraph/graph/scene.cpp b/src/nodegraph/graph/scene.cpp index e24380e2..e0692e78 100644 --- a/src/nodegraph/graph/scene.cpp +++ b/src/nodegraph/graph/scene.cpp @@ -120,9 +120,11 @@ ConnectionPtr Scene::connectNodes(NodePtr leftNode, QString leftOutputName, NodePtr rightNode, QString rightInputName) { auto leftPort = leftNode->getOutPortByName(leftOutputName); - qDebug() << rightNode->getInPorts(); auto rightPort = rightNode->getInPortByName(rightInputName); + if (!leftPort || !rightPort) + return ConnectionPtr(nullptr); + // create new connection item from ports auto conn = new Connection(); conn->startPort = leftPort; @@ -404,7 +406,7 @@ PortPtr Node::getPortById(QString id) return port; } - Q_ASSERT(false); + return PortPtr(nullptr); } PortPtr Node::getInPortByName(QString name) @@ -414,7 +416,7 @@ PortPtr Node::getInPortByName(QString name) return port; } - Q_ASSERT(false); + return PortPtr(nullptr); } PortPtr Node::getOutPortByName(QString name) @@ -424,7 +426,7 @@ PortPtr Node::getOutPortByName(QString name) return port; } - Q_ASSERT(false); + return PortPtr(nullptr); } QRectF Node::boundingRect() const { return QRectF(0, 0, 100, 100); } From 3ff80ba636605cd04d04303527fe4b8ac979c2d9 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 19 Jul 2026 13:55:23 -0500 Subject: [PATCH 127/164] tidy up some init and pointer logic --- src/nodegraph/graph/scene.cpp | 16 +++++++++------- src/nodegraph/graph/scene.h | 17 +++++++++++++---- src/texturelab/models.h | 6 +++--- src/texturelab/props.h | 6 +++--- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/nodegraph/graph/scene.cpp b/src/nodegraph/graph/scene.cpp index e0692e78..0e5c278b 100644 --- a/src/nodegraph/graph/scene.cpp +++ b/src/nodegraph/graph/scene.cpp @@ -200,6 +200,8 @@ void Scene::removeNode(NodePtr node) node->hide(); // fix display cache issue this->removeItem(node.data()); + nodes.remove(node->id()); + // reshow here in case i forget when re-adding node for // undo-redo node->show(); @@ -806,8 +808,8 @@ void Connection::updatePosFromPorts() void Connection::updatePathFromPositions() { - p = new QPainterPath; - p->moveTo(pos1); + p = QPainterPath(); + p.moveTo(pos1); qreal dx = pos2.x() - pos1.x(); qreal dy = pos2.y() - pos1.y(); @@ -815,10 +817,10 @@ void Connection::updatePathFromPositions() QPointF ctr1(pos1.x() + dx * 0.5, pos1.y()); QPointF ctr2(pos2.x() - dx * 0.5, pos2.y()); - p->cubicTo(ctr1, ctr2, pos2); - p->setFillRule(Qt::OddEvenFill); + p.cubicTo(ctr1, ctr2, pos2); + p.setFillRule(Qt::OddEvenFill); - setPath(*p); + setPath(p); } void Connection::paint(QPainter* painter, @@ -832,7 +834,7 @@ void Connection::paint(QPainter* painter, pen.setStyle(Qt::DashLine); pen.setDashOffset(4); painter->setPen(pen); - painter->drawPath(*p); + painter->drawPath(p); painter->setPen(QPen(QColor(0, 0, 0), 3)); painter->setBrush(QBrush(QColor(150, 150, 150))); @@ -845,7 +847,7 @@ void Connection::paint(QPainter* painter, // create gradient for line QPen pen(QColor(170, 170, 170), lineThickness); painter->setPen(pen); - painter->drawPath(*p); + painter->drawPath(p); painter->setPen(QPen(QColor(0, 0, 0), 3)); painter->setBrush(QBrush(QColor(170, 170, 170))); diff --git a/src/nodegraph/graph/scene.h b/src/nodegraph/graph/scene.h index 11449c4c..0c20a9f2 100644 --- a/src/nodegraph/graph/scene.h +++ b/src/nodegraph/graph/scene.h @@ -5,9 +5,9 @@ #include #include #include +#include #include #include -#include #include #include #include @@ -33,7 +33,13 @@ typedef QSharedPointer ScenePtr; typedef QSharedPointer FramePtr; typedef QSharedPointer CommentPtr; -enum class SceneItemType : int { Node = 1, Port = 2, Connection = 3, Comment = 4, Frame = 5 }; +enum class SceneItemType : int { + Node = 1, + Port = 2, + Connection = 3, + Comment = 4, + Frame = 5 +}; class Scene : public QGraphicsScene, public QEnableSharedFromThis { public: @@ -93,7 +99,7 @@ class Node : public QGraphicsObject, public QEnableSharedFromThis { QColor defaultBorderColor; QColor highlightBorderColor; QColor selectedBorderColor; - + // Modern OpenGL resources static QOpenGLShaderProgram* shaderProgram; static QOpenGLBuffer* vbo; @@ -238,7 +244,10 @@ class Connection : public QGraphicsPathItem, void updatePosFromPorts(); void updatePathFromPositions(); - QPainterPath* p; + // Value member (was a raw QPainterPath* that leaked on every + // updatePathFromPositions() call and was read uninitialized before the + // first update). + QPainterPath p; // virtual int type() const override; void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, diff --git a/src/texturelab/models.h b/src/texturelab/models.h index 9ccd6f42..010becbb 100644 --- a/src/texturelab/models.h +++ b/src/texturelab/models.h @@ -64,7 +64,7 @@ class ProjectFile { class TextureProject : public QEnableSharedFromThis { public: QString name = "untitled"; - int randomSeed; + int randomSeed = 0; int textureWidth = 1024; int textureHeight = 1024; @@ -127,8 +127,8 @@ class TextureNode : public QEnableSharedFromThis { // flag to indicate this node processes on CPU instead of GPU shader bool usesCpuProcessing = false; - int textureWidth; - int textureHeight; + int textureWidth = 0; + int textureHeight = 0; QOpenGLFramebufferObject* texture = nullptr; QOpenGLShaderProgram* shader = nullptr; QString shaderSource; diff --git a/src/texturelab/props.h b/src/texturelab/props.h index eb63308d..dd7fb098 100644 --- a/src/texturelab/props.h +++ b/src/texturelab/props.h @@ -1,7 +1,7 @@ #pragma once -#include "curve.h" #include "../colorpicker/gradient.h" +#include "curve.h" #include #include #include @@ -46,7 +46,7 @@ class Prop { QString name; QString displayName; PropType::Value type; - int order = 0;// for tracking order in UI + int order = 0; // for tracking order in UI PropertyGroup* group = nullptr; @@ -288,7 +288,7 @@ class EnumProp : public Prop { index = obj["index"].toInt(); auto list = obj["values"].toArray(); - values.empty(); + values.clear(); for (auto item : list) { values.append(item.toString()); } From e0133cdfb949c62e3971bfad0087b302f2a8d8a4 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 19 Jul 2026 14:33:45 -0500 Subject: [PATCH 128/164] force deployment to use keyring over gh tokens --- gh-build.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/gh-build.sh b/gh-build.sh index c27b2c22..8b79f09e 100755 --- a/gh-build.sh +++ b/gh-build.sh @@ -1,6 +1,13 @@ #!/usr/bin/env bash set -euo pipefail +source .env + +# Force gh to use the keyring login (which has repo scope). gh prefers the +# GH_TOKEN / GITHUB_TOKEN env vars over keyring auth, and the token exported +# from the shell only has read:packages scope, which 403s on workflow dispatch. +unset GH_TOKEN GITHUB_TOKEN + REPO="njbrown/texturelab" WORKFLOW="build.yml" BRANCH="${1:-$(git rev-parse --abbrev-ref HEAD)}" From 01c102f1ee5af2aa90b5961eff75bc9ef800d0ae Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 19 Jul 2026 15:13:21 -0500 Subject: [PATCH 129/164] add app version to status bar --- src/texturelab/CMakeLists.txt | 9 ++++++++- src/texturelab/mainwindow.cpp | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index bc4ec126..c6c2169e 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -2,6 +2,13 @@ cmake_minimum_required(VERSION 3.10) project(texturelab VERSION 0.4.0 LANGUAGES CXX) +# Optional pre-release suffix appended to the numeric project() version above to +# form the full app version (e.g. "0.4.0" + "-beta" = "0.4.0-beta"); leave empty +# for a final release. The full string is the in-app version / Sentry release +# (TEXTURELAB_VERSION compile def below) and is reconstructed from these same two +# lines by the build workflow (.github/workflows/build.yml) for artifact names. +set(TEXTURELAB_VERSION_SUFFIX "-beta") + set(CMAKE_INCLUDE_CURRENT_DIR ON) set(CMAKE_AUTOUIC ON) @@ -272,7 +279,7 @@ if(NOT DEFINED TEXTURELAB_SENTRY_DSN) endif() target_compile_definitions(texturelab PRIVATE - TEXTURELAB_VERSION="0.4.0-beta" + TEXTURELAB_VERSION="${PROJECT_VERSION}${TEXTURELAB_VERSION_SUFFIX}" TEXTURELAB_SENTRY_DSN="${TEXTURELAB_SENTRY_DSN}" ) diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 57d20a02..3ff19fb5 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -78,6 +79,13 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) statusLayout->setContentsMargins(0, 0, 0, 0); statusLayout->setSpacing(6); statusLayout->addStretch(); + // Version + build hash on the left so it's legible in screenshots + // (matches the build artifact name, e.g. texturelab-win-v0.4.0-beta-). + auto* versionLabel = new QLabel(QCoreApplication::applicationVersion()); + versionLabel->setStyleSheet("color: #888888; padding: 0 6px;"); + versionLabel->setToolTip("Application version and build hash"); + statusBar()->addWidget(versionLabel); + statusLayout->addWidget(statusLabel, 0, Qt::AlignVCenter); statusLayout->addWidget(progressBar, 0, Qt::AlignVCenter); statusBar()->addWidget(statusWidget, 1); From 914445814b2ae0ff7adeb5d66ad941a6fd72c166 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 19 Jul 2026 15:18:54 -0500 Subject: [PATCH 130/164] add build version and hash to final builds --- .github/workflows/build.yml | 83 ++++++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cfcb5d81..cd3b2f55 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,28 @@ on: default: false jobs: + # Single source of truth for the build tag used in artifact names. + # tag = v-, where the short hash is the + # SAME `git rev-parse --short HEAD` the app embeds (TEXTURELAB_BUILD_HASH), so + # a build file name matches the version shown in-app / in screenshots. + version: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.v.outputs.tag }} + steps: + - uses: actions/checkout@v4 + - name: Compute build tag + id: v + run: | + # tag = v-, where is the numeric + # project() version + optional suffix — the same string the app embeds + # as TEXTURELAB_VERSION, so file names match the in-app version. + NUM=$(grep -oP 'project\(texturelab VERSION \K[0-9.]+' src/texturelab/CMakeLists.txt) + SUFFIX=$(grep -oP 'set\(TEXTURELAB_VERSION_SUFFIX "\K[^"]*' src/texturelab/CMakeLists.txt) + echo "tag=v${NUM}${SUFFIX}-$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + build-linux: + needs: version # runs-on: ubuntu-20.04 runs-on: ubuntu-22.04 @@ -120,13 +141,19 @@ jobs: --plugin qt \ --output appimage + - name: Rename Linux artifact + run: | + APP=$(ls *.AppImage | head -1) + mv "$APP" "texturelab-linux-${{ needs.version.outputs.tag }}.AppImage" + - name: Upload Linux artifact uses: actions/upload-artifact@v4 with: - name: texturelab-linux - path: "*.AppImage" + name: texturelab-linux-${{ needs.version.outputs.tag }} + path: "texturelab-linux-${{ needs.version.outputs.tag }}.AppImage" build-windows: + needs: version runs-on: windows-2022 steps: @@ -196,13 +223,21 @@ jobs: Write-Host "waiting for crashpad to upload the minidump..." Start-Sleep -Seconds 25 + # Rename after the crash-test step above (which runs deploy\texturelab.exe) + # so the exe ships as texturelab-win-v-.exe alongside its DLLs. + - name: Rename Windows executable + shell: pwsh + run: | + Rename-Item "deploy\texturelab.exe" "texturelab-win-${{ needs.version.outputs.tag }}.exe" + - name: Upload Windows artifact uses: actions/upload-artifact@v4 with: - name: texturelab-windows + name: texturelab-win-${{ needs.version.outputs.tag }} path: deploy/ build-macos: + needs: version # Qt 6.7.x still references AGL.framework, which modern macOS SDKs (Xcode 16+) # removed — so we pin Xcode 15 (macOS 14 SDK, AGL present) on macos-14. Build # universal x86_64+arm64 so it runs on both Intel and Apple Silicon Macs. @@ -270,13 +305,15 @@ jobs: - name: Upload macOS artifact uses: actions/upload-artifact@v4 with: - name: texturelab-macos + name: texturelab-mac-${{ needs.version.outputs.tag }} path: build/src/texturelab/texturelab.app deploy: - needs: [build-linux, build-windows, build-macos] + needs: [version, build-linux, build-windows, build-macos] if: always() runs-on: ubuntu-latest + env: + TAG: ${{ needs.version.outputs.tag }} steps: - name: Download artifacts if: ${{ needs.build-linux.result == 'success' || needs.build-windows.result == 'success' || needs.build-macos.result == 'success' }} @@ -290,14 +327,32 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: us-east-2 - SHA: ${{ github.sha }} run: | + # Artifacts already arrive as texturelab--/ with the + # primary file (exe/AppImage) renamed. Here we just zip each bundle + # under the same texturelab--.zip name. cd artifacts - if [ -d texturelab-linux ]; then zip -r ../linux-$SHA.zip texturelab-linux/; fi - if [ -d texturelab-windows ]; then zip -r ../windows-$SHA.zip texturelab-windows/; fi - if [ -d texturelab-macos ]; then zip -r ../macos-$SHA.zip texturelab-macos/; fi + + # Linux — single AppImage. + if [ -d "texturelab-linux-${TAG}" ]; then + zip -j "../texturelab-linux-${TAG}.zip" "texturelab-linux-${TAG}"/*.AppImage + fi + + # Windows — exe + sibling Qt DLLs + crashpad_handler. + if [ -d "texturelab-win-${TAG}" ]; then + (cd "texturelab-win-${TAG}" && zip -r "../../texturelab-win-${TAG}.zip" .) + fi + + # macOS — re-wrap the uploaded bundle contents as a proper .app, zip it. + if [ -d "texturelab-mac-${TAG}" ]; then + mkdir -p "wrap/texturelab.app" + cp -R "texturelab-mac-${TAG}/." "wrap/texturelab.app/" + (cd wrap && zip -r "../../texturelab-mac-${TAG}.zip" texturelab.app) + rm -rf wrap + fi + cd .. - for f in linux-$SHA.zip windows-$SHA.zip macos-$SHA.zip; do + for f in texturelab-linux-${TAG}.zip texturelab-win-${TAG}.zip texturelab-mac-${TAG}.zip; do if [ -f "$f" ]; then aws s3 cp "$f" s3://texturelab-nightlies/; fi done @@ -305,14 +360,12 @@ jobs: env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} BRANCH: ${{ github.ref_name }} - SHA: ${{ github.sha }} RUN_ID: ${{ github.run_id }} REPO: ${{ github.repository }} LINUX_RESULT: ${{ needs.build-linux.result }} WINDOWS_RESULT: ${{ needs.build-windows.result }} MACOS_RESULT: ${{ needs.build-macos.result }} run: | - SHORT_SHA="${SHA:0:7}" RUN_URL="https://github.com/$REPO/actions/runs/$RUN_ID" S3_BASE="https://texturelab-nightlies.s3.us-east-2.amazonaws.com" @@ -335,14 +388,14 @@ jobs: } if [[ "$LINUX_RESULT" == "success" && "$WINDOWS_RESULT" == "success" && "$MACOS_RESULT" == "success" ]]; then - TITLE="Build succeeded — $BRANCH @ $SHORT_SHA" + TITLE="Build succeeded — $BRANCH @ $TAG" COLOR=3066993 else - TITLE="Build failed — $BRANCH @ $SHORT_SHA" + TITLE="Build failed — $BRANCH @ $TAG" COLOR=15158332 fi - DESCRIPTION="$(download_line "$LINUX_RESULT" "Linux" "$S3_BASE/linux-$SHA.zip")\n$(download_line "$WINDOWS_RESULT" "Windows" "$S3_BASE/windows-$SHA.zip")\n$(download_line "$MACOS_RESULT" "macOS" "$S3_BASE/macos-$SHA.zip")\n\n[View run]($RUN_URL)" + DESCRIPTION="$(download_line "$LINUX_RESULT" "Linux" "$S3_BASE/texturelab-linux-$TAG.zip")\n$(download_line "$WINDOWS_RESULT" "Windows" "$S3_BASE/texturelab-win-$TAG.zip")\n$(download_line "$MACOS_RESULT" "macOS" "$S3_BASE/texturelab-mac-$TAG.zip")\n\n[View run]($RUN_URL)" curl -s -X POST "$DISCORD_WEBHOOK" \ -H "Content-Type: application/json" \ From 4b803e149187f3c5384b56ef317feb160ccc5cec Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Mon, 27 Jul 2026 12:55:50 -0500 Subject: [PATCH 131/164] lay ground work for theming --- CMakeLists.txt | 3 + resources/qss/app.qss.in | 272 ++++++++++++++++++++++++++++++++++ resources/theme.qrc | 8 + resources/themes/dark.json | 93 ++++++++++++ src/texturelab/CMakeLists.txt | 8 +- src/texturelab/main.cpp | 34 +++++ src/texturelab/mainwindow.cpp | 2 +- src/theme/CMakeLists.txt | 36 +++++ src/theme/qssbuilder.cpp | 37 +++++ src/theme/qssbuilder.h | 16 ++ src/theme/theme.cpp | 11 ++ src/theme/theme.h | 48 ++++++ src/theme/thememanager.cpp | 247 ++++++++++++++++++++++++++++++ src/theme/thememanager.h | 69 +++++++++ src/theme/tokens.h | 44 ++++++ 15 files changed, 926 insertions(+), 2 deletions(-) create mode 100644 resources/qss/app.qss.in create mode 100644 resources/theme.qrc create mode 100644 resources/themes/dark.json create mode 100644 src/theme/CMakeLists.txt create mode 100644 src/theme/qssbuilder.cpp create mode 100644 src/theme/qssbuilder.h create mode 100644 src/theme/theme.cpp create mode 100644 src/theme/theme.h create mode 100644 src/theme/thememanager.cpp create mode 100644 src/theme/thememanager.h create mode 100644 src/theme/tokens.h diff --git a/CMakeLists.txt b/CMakeLists.txt index fd3d45a5..31d86698 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,6 +39,9 @@ set(BUILD_EXAMPLES OFF CACHE BOOL "Don't build ADS examples" FORCE) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src/ads) # set_target_properties(qtadvanceddocking-qt6 PROPERTIES BUILD_STATIC TRUE) +# Theme / design-token system (single source of truth for colors, QSS, palette) +add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src/theme) + # Node Graph add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src/nodegraph) diff --git a/resources/qss/app.qss.in b/resources/qss/app.qss.in new file mode 100644 index 00000000..2e3baf65 --- /dev/null +++ b/resources/qss/app.qss.in @@ -0,0 +1,272 @@ +/* + * TextureLab application stylesheet (template). + * + * Double-brace placeholders are substituted from the active theme JSON by + * QssBuilder at load time -- a placeholder naming the color token bg.panel + * becomes #353535, radius.sm becomes 3, font.ui.family becomes the UI font + * family, and so on. (This comment avoids writing a literal placeholder so the + * substituter has nothing to flag.) + * + * PHASE 1 -- app chrome: menus, toolbars, status bar, and the common controls + * that appear across every panel (buttons, inputs, combos, checks, sliders, + * scrollbars, tooltips, splitters). Panel-specific styling lands in later + * phases (see UI_DESIGN_SYSTEM_PRD.md section 6). + * + * Edit + save this file with the app running as `--dev-theme` to see changes + * live (no rebuild). See section on hot-reload in the PRD. + */ + +/* ============================================================= base ======= */ + +QWidget { + font-family: {{font.ui.family}}; + font-size: {{font.ui.size}}px; + color: {{text.primary}}; +} + +QToolTip { + background: {{bg.elevated}}; + color: {{text.primary}}; + border: 1px solid {{border.subtle}}; + padding: 3px 6px; +} + +/* ======================================================= menu bar ========= */ + +QMenuBar { + background: {{bg.panel}}; + border: none; + padding: 2px 4px; +} +QMenuBar::item { + background: transparent; + padding: 4px 10px; + border-radius: {{radius.sm}}px; +} +QMenuBar::item:selected { background: {{ctrl.hover}}; } +QMenuBar::item:pressed { background: {{selection}}; color: {{text.primary}}; } + +QMenu { + background: {{bg.elevated}}; + border: 1px solid {{border.subtle}}; + padding: 4px; +} +QMenu::item { + padding: 5px 24px 5px 22px; + border-radius: {{radius.sm}}px; +} +QMenu::item:selected { background: {{selection}}; color: {{text.primary}}; } +QMenu::item:disabled { color: {{text.disabled}}; } +QMenu::separator { + height: 1px; + background: {{border.subtle}}; + margin: 4px 8px; +} +QMenu::icon { padding-left: 6px; } + +/* ======================================================= tool bar ========= */ + +QToolBar { + background: {{bg.panel}}; + border: none; + border-bottom: 1px solid {{border.subtle}}; + padding: 3px; + spacing: {{space.sm}}px; +} +QToolBar::separator { + width: 1px; + background: {{border.subtle}}; + margin: 4px 4px; +} +QToolButton { + background: transparent; + border: 1px solid transparent; + border-radius: {{radius.sm}}px; + padding: 4px 6px; +} +QToolButton:hover { background: {{ctrl.hover}}; } +QToolButton:pressed { background: {{ctrl.pressed}}; } +QToolButton:checked { background: {{selection}}; border-color: {{accent.press}}; } +QToolButton::menu-indicator { image: none; } + +/* ====================================================== status bar ======== */ + +QStatusBar { + background: {{bg.panel}}; + border-top: 1px solid {{border.subtle}}; +} +QStatusBar::item { border: none; } +QStatusBar QLabel { background: transparent; } + +#StatusVersionLabel { + color: {{text.disabled}}; + padding: 0 6px; +} + +/* ========================================================= buttons ======== */ + +QPushButton { + background: {{ctrl.bg}}; + border: 1px solid {{border.input}}; + border-radius: {{radius.sm}}px; + padding: 4px 12px; + min-height: 18px; +} +QPushButton:hover { background: {{ctrl.hover}}; } +QPushButton:pressed { background: {{ctrl.pressed}}; } +QPushButton:disabled { color: {{text.disabled}}; border-color: {{border.subtle}}; } +QPushButton:default { border-color: {{accent}}; } + +QPushButton[variant="primary"] { + background: {{accent}}; + border: 1px solid {{accent.press}}; + color: {{text.primary}}; +} +QPushButton[variant="primary"]:hover { background: {{accent.hover}}; } +QPushButton[variant="primary"]:pressed { background: {{accent.press}}; } + +/* ==================================================== text inputs ========= */ + +QLineEdit, QPlainTextEdit, QTextEdit, QSpinBox, QDoubleSpinBox { + background: {{bg.input}}; + border: 1px solid {{border.input}}; + border-radius: {{radius.sm}}px; + padding: 3px 6px; + selection-background-color: {{selection}}; + selection-color: {{text.primary}}; +} +QLineEdit:focus, QPlainTextEdit:focus, QTextEdit:focus, +QSpinBox:focus, QDoubleSpinBox:focus { + border-color: {{accent}}; +} +QLineEdit:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled { + color: {{text.disabled}}; +} + +QSpinBox::up-button, QDoubleSpinBox::up-button, +QSpinBox::down-button, QDoubleSpinBox::down-button { + width: 14px; + background: transparent; + border: none; +} +QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover, +QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover { + background: {{ctrl.hover}}; +} + +/* ==================================================== combo boxes ========= */ + +QComboBox { + background: {{ctrl.bg}}; + border: 1px solid {{border.input}}; + border-radius: {{radius.sm}}px; + padding: 3px 6px; + min-height: 18px; +} +QComboBox:hover { background: {{ctrl.hover}}; } +QComboBox:focus { border-color: {{accent}}; } +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: center right; + width: 18px; + border: none; +} +QComboBox QAbstractItemView { + background: {{bg.elevated}}; + border: 1px solid {{border.subtle}}; + selection-background-color: {{selection}}; + selection-color: {{text.primary}}; + outline: none; +} + +/* ============================================= checks & radios ============ */ + +QCheckBox, QRadioButton { spacing: 6px; background: transparent; } +QCheckBox::indicator, QRadioButton::indicator { + width: 15px; + height: 15px; + background: {{bg.input}}; + border: 1px solid {{border.input}}; +} +QCheckBox::indicator { border-radius: {{radius.sm}}px; } +QRadioButton::indicator { border-radius: 8px; } +QCheckBox::indicator:hover, QRadioButton::indicator:hover { border-color: {{accent}}; } +QCheckBox::indicator:checked, QRadioButton::indicator:checked { + background: {{ctrl.checked}}; + border-color: {{accent.press}}; +} + +/* ========================================================= sliders ======== */ + +QSlider::groove:horizontal { + height: 4px; + background: {{bg.input}}; + border-radius: 2px; +} +QSlider::sub-page:horizontal { background: {{accent}}; border-radius: 2px; } +QSlider::handle:horizontal { + background: {{text.secondary}}; + width: 12px; + height: 12px; + margin: -5px 0; + border-radius: 6px; +} +QSlider::handle:horizontal:hover { background: {{text.primary}}; } + +/* ======================================================= scrollbars ======= */ + +QScrollBar:vertical { + background: transparent; + width: 12px; + margin: 0; +} +QScrollBar:horizontal { + background: transparent; + height: 12px; + margin: 0; +} +QScrollBar::handle:vertical, QScrollBar::handle:horizontal { + background: {{gray.600}}; + border-radius: 4px; + border: 2px solid transparent; + background-clip: padding; +} +QScrollBar::handle:vertical { min-height: 28px; } +QScrollBar::handle:horizontal { min-width: 28px; } +QScrollBar::handle:hover { background: {{gray.550}}; } +QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; background: none; } +QScrollBar::add-page, QScrollBar::sub-page { background: none; } + +/* ========================================================= tab bar ======== */ + +QTabBar::tab { + background: {{bg.window}}; + color: {{text.secondary}}; + padding: 5px 12px; + border: none; +} +QTabBar::tab:selected { + background: {{bg.panel}}; + color: {{text.primary}}; + border-bottom: 2px solid {{accent}}; +} +QTabBar::tab:hover:!selected { color: {{text.primary}}; } + +/* ========================================================= splitter ======= */ + +QSplitter::handle { background: {{border.subtle}}; } +QSplitter::handle:horizontal { width: 1px; } +QSplitter::handle:vertical { height: 1px; } +QSplitter::handle:hover { background: {{accent}}; } + +/* ======================================================= progress ========= */ + +QProgressBar { + background: {{bg.input}}; + border: none; + border-radius: {{radius.sm}}px; + text-align: center; + color: {{text.primary}}; + max-height: 14px; +} +QProgressBar::chunk { background: {{accent}}; border-radius: {{radius.sm}}px; } diff --git a/resources/theme.qrc b/resources/theme.qrc new file mode 100644 index 00000000..1fd90802 --- /dev/null +++ b/resources/theme.qrc @@ -0,0 +1,8 @@ + + + themes/dark.json + + + qss/app.qss.in + + diff --git a/resources/themes/dark.json b/resources/themes/dark.json new file mode 100644 index 00000000..89157f19 --- /dev/null +++ b/resources/themes/dark.json @@ -0,0 +1,93 @@ +{ + "meta": { "name": "TextureLab Dark", "base": "dark" }, + + "color": { + "gray.900": "#191919", + "gray.850": "#232323", + "gray.800": "#2B2B2B", + "gray.750": "#2E2E2E", + "gray.700": "#353535", + "gray.650": "#404040", + "gray.600": "#505050", + "gray.550": "#5C5C5C", + "gray.400": "#7F7F7F", + "gray.300": "#787878", + "gray.200": "#C8C8C8", + "white": "#FFFFFF", + "black": "#000000", + "accent": "#2A82DA", + "accent.hover": "#3D93E8", + "accent.press": "#2069B8", + "warn": "#E5A54B", + "danger": "#E5484D", + "ok": "#3DAF6E", + + "bg.window": "@gray.700", + "bg.panel": "@gray.700", + "bg.base": "@gray.850", + "bg.elevated": "@gray.900", + "bg.input": "@gray.750", + "border.subtle": "@gray.900", + "border.strong": "@black", + "border.input": "@gray.600", + "text.primary": "@white", + "text.secondary": "@gray.200", + "text.disabled": "@gray.400", + "selection": "@accent", + + "ctrl.bg": "@gray.650", + "ctrl.hover": "@gray.600", + "ctrl.pressed": "@gray.550", + "ctrl.checked": "@accent", + + "node.bg": "@gray.700", + "node.bg.selected": "@gray.600", + "node.border": "@black", + "node.border.hover": "@gray.300", + "node.border.select": "@gray.200", + "node.title": "@white", + "wire": "@gray.200", + "wire.selected": "@accent", + "port.in": "@accent", + "port.out": "@ok", + "grid.dot": "@gray.600", + "grid.bg": "@gray.850", + "checker.a": "@gray.700", + "checker.b": "@gray.600", + + "view3d.clear": "@gray.700", + "view3d.grid": "@gray.600" + }, + + "palette": { + "window": "@bg.window", + "windowText": "@text.primary", + "base": "@bg.base", + "alternateBase": "@bg.window", + "toolTipBase": "@gray.900", + "toolTipText": "@text.primary", + "text": "@text.primary", + "button": "@bg.window", + "buttonText": "@text.primary", + "brightText": "@danger", + "link": "@accent", + "highlight": "@accent", + "highlightedText": "@black", + + "disabled.windowText": "@text.disabled", + "disabled.text": "@text.disabled", + "disabled.buttonText": "@text.disabled", + "disabled.highlightedText": "@text.disabled", + "disabled.highlight": "@gray.600" + }, + + "radius": { "sm": 3, "md": 5, "lg": 8, "pill": 999 }, + "space": { "xs": 2, "sm": 4, "md": 8, "lg": 12, "xl": 16 }, + "motion": { "fast": 120, "base": 180, "slow": 260 }, + + "font": { + "ui": { "family": "Segoe UI, Inter, sans-serif", "size": 12, "weight": 400 }, + "mono": { "family": "Consolas, JetBrains Mono, monospace", "size": 12, "weight": 400 }, + "title": { "family": "Segoe UI, Inter, sans-serif", "size": 13, "weight": 600 } + } +} diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index c6c2169e..155aa3e8 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -240,7 +240,8 @@ set(PROJECT_SOURCES ) set(PROJECT_RESOURCES - ./assets.qrc) + ./assets.qrc + ${CMAKE_SOURCE_DIR}/resources/theme.qrc) if(${QT_VERSION_MAJOR} GREATER_EQUAL 6) qt_add_executable(texturelab @@ -261,6 +262,7 @@ target_link_libraries(texturelab PRIVATE Qt${QT_VERSION_MAJOR}::Widgets Qt${QT_VERSION_MAJOR}::OpenGLWidgets OpenGL::GL qtadvanceddocking-qt6 + theme nodegraph viewer3d colorpicker @@ -269,6 +271,7 @@ target_link_libraries(texturelab PRIVATE Qt${QT_VERSION_MAJOR}::Widgets target_include_directories(texturelab PUBLIC ../ads/src + ../theme ../nodegraph ../viewer3d ../colorpicker @@ -281,6 +284,9 @@ endif() target_compile_definitions(texturelab PRIVATE TEXTURELAB_VERSION="${PROJECT_VERSION}${TEXTURELAB_VERSION_SUFFIX}" TEXTURELAB_SENTRY_DSN="${TEXTURELAB_SENTRY_DSN}" + # Absolute path to the theme source files, so `--dev-theme` can live-reload + # colors/QSS from disk without a rebuild. Dev convenience only. + TEXTURELAB_SOURCE_RESOURCES="${CMAKE_SOURCE_DIR}/resources" ) # Generate version.h (with git hash) at every build, not just configure time diff --git a/src/texturelab/main.cpp b/src/texturelab/main.cpp index 078de071..2151b910 100644 --- a/src/texturelab/main.cpp +++ b/src/texturelab/main.cpp @@ -1,5 +1,6 @@ #include "mainwindow.h" #include "telemetry.h" +#include "thememanager.h" #include "version.h" #include @@ -58,6 +59,19 @@ TL_NOINLINE static void sentryCrashTest() *p = 0xC0FFEE; } +// Force a consistent dark theme on every platform, independent of the host +// system theme. Loads the design-token theme (Fusion + dark palette + app +// stylesheet) from resources so it works even in a minimal Linux AppImage that +// has no desktop theme plugin — which is why CI builds otherwise render in light +// mode. See src/theme/ and UI_DESIGN_SYSTEM_PRD.md. +static void applyDarkTheme(QApplication& app) +{ + ThemeManager& tm = ThemeManager::instance(); + tm.loadFromResource(":/themes/dark.json"); + tm.setStyleSheetTemplate(":/qss/app.qss"); + tm.applyToApplication(app); +} + int main(int argc, char* argv[]) { // Read opt-out before constructing QApplication so we can use QSettings @@ -89,6 +103,26 @@ int main(int argc, char* argv[]) a.setApplicationName("texturelab"); a.setApplicationVersion(QString(TEXTURELAB_VERSION) + "+" + TEXTURELAB_BUILD_HASH); + // Consistent dark UI on every platform, regardless of the host system theme. + applyDarkTheme(a); + + // Dev convenience: `--dev-theme` live-reloads the theme from the on-disk + // source files (resources/…) on save, so colors and QSS can be tuned without + // rebuilding. Off by default; production always uses the compiled-in resources. + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--dev-theme") == 0) { +#ifdef TEXTURELAB_SOURCE_RESOURCES + const QString res = QStringLiteral(TEXTURELAB_SOURCE_RESOURCES); + ThemeManager::instance().enableHotReload(res + "/themes/dark.json", + res + "/qss/app.qss.in"); + qInfo("Theme hot-reload enabled, watching %s", qPrintable(res)); +#else + qWarning("--dev-theme: TEXTURELAB_SOURCE_RESOURCES not compiled in"); +#endif + break; + } + } + // Now applicationDirPath() is valid — init Sentry Telemetry::init(crashReportingEnabled); diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 3ff19fb5..befb1183 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -82,7 +82,7 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) // Version + build hash on the left so it's legible in screenshots // (matches the build artifact name, e.g. texturelab-win-v0.4.0-beta-). auto* versionLabel = new QLabel(QCoreApplication::applicationVersion()); - versionLabel->setStyleSheet("color: #888888; padding: 0 6px;"); + versionLabel->setObjectName("StatusVersionLabel"); // styled in resources/qss/app.qss.in versionLabel->setToolTip("Application version and build hash"); statusBar()->addWidget(versionLabel); diff --git a/src/theme/CMakeLists.txt b/src/theme/CMakeLists.txt new file mode 100644 index 00000000..3fab8498 --- /dev/null +++ b/src/theme/CMakeLists.txt @@ -0,0 +1,36 @@ +cmake_minimum_required(VERSION 3.10) + +find_package(QT NAMES Qt6 Qt5 COMPONENTS Core REQUIRED) +find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Core Gui Widgets REQUIRED) + +set(CMAKE_INCLUDE_CURRENT_DIR ON) + +set(THEME_SRCS + theme.cpp + qssbuilder.cpp + thememanager.cpp +) +set(THEME_HEADERS + tokens.h + theme.h + qssbuilder.h + thememanager.h +) + +add_library(theme STATIC ${THEME_SRCS} ${THEME_HEADERS}) + +target_link_libraries(theme PUBLIC Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::Widgets) + +target_include_directories(theme PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + +set_target_properties(theme PROPERTIES + AUTOMOC ON + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + CXX_EXTENSIONS OFF + ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" +) diff --git a/src/theme/qssbuilder.cpp b/src/theme/qssbuilder.cpp new file mode 100644 index 00000000..0726c37b --- /dev/null +++ b/src/theme/qssbuilder.cpp @@ -0,0 +1,37 @@ +#include "qssbuilder.h" + +#include "theme.h" + +#include + +QString QssBuilder::build(const QString& templateText, const Theme& theme) +{ + const QHash& vars = theme.qssVars(); + + // Matches {{ token.name }} with optional surrounding whitespace. + static const QRegularExpression re(QStringLiteral("\\{\\{\\s*([^}\\s]+)\\s*\\}\\}")); + + QString out; + out.reserve(templateText.size()); + + qsizetype last = 0; + auto it = re.globalMatch(templateText); + while (it.hasNext()) { + const QRegularExpressionMatch m = it.next(); + out += templateText.mid(last, m.capturedStart() - last); + + const QString key = m.captured(1); + auto found = vars.constFind(key); + if (found != vars.constEnd()) { + out += found.value(); + } + else { + qWarning("QssBuilder: unknown token '{{%s}}' left unsubstituted", + qPrintable(key)); + out += m.captured(0); // leave placeholder so the miss is visible + } + last = m.capturedEnd(); + } + out += templateText.mid(last); + return out; +} diff --git a/src/theme/qssbuilder.h b/src/theme/qssbuilder.h new file mode 100644 index 00000000..ce5478c6 --- /dev/null +++ b/src/theme/qssbuilder.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +class Theme; + +// Turns an authored QSS template (with {{token}} placeholders) into a final +// stylesheet string by substituting values from the active theme. +namespace QssBuilder { + +// Substitute every {{token}} in `templateText` with theme.qssVars()[token]. +// Unknown tokens are left as-is and a warning is logged, so a typo is visible +// in the rendered CSS rather than silently blanking a rule. +QString build(const QString& templateText, const Theme& theme); + +} // namespace QssBuilder diff --git a/src/theme/theme.cpp b/src/theme/theme.cpp new file mode 100644 index 00000000..fb13dd04 --- /dev/null +++ b/src/theme/theme.cpp @@ -0,0 +1,11 @@ +#include "theme.h" + +QColor Theme::color(const QString& key) const +{ + auto it = m_colors.constFind(key); + if (it == m_colors.constEnd()) { + qWarning("Theme: unknown color token '%s'", qPrintable(key)); + return QColor(255, 0, 255); // loud magenta = obvious mistake + } + return it.value(); +} diff --git a/src/theme/theme.h b/src/theme/theme.h new file mode 100644 index 00000000..bf4d0f8a --- /dev/null +++ b/src/theme/theme.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include +#include + +// A resolved, immutable snapshot of one theme. Produced by ThemeManager from a +// theme JSON file (all "@ref" indirection already flattened to concrete values). +// +// Consumers: +// - QssBuilder reads the flat string map (qssVars) for {{token}} substitution. +// - Custom paint code reads typed values via color()/radius()/space()/font(). +// - ThemeManager reads paletteSpec to build a QPalette. +class Theme +{ +public: + Theme() = default; + + // Typed lookups for paint-time code. A missing color returns a loud magenta + // (so a mistyped token is obvious on screen rather than silently black). + QColor color(const QString& key) const; + bool hasColor(const QString& key) const { return m_colors.contains(key); } + int radius(const QString& key) const { return m_ints.value("radius." + key, 0); } + int space(const QString& key) const { return m_ints.value("space." + key, 0); } + int motion(const QString& key) const { return m_ints.value("motion." + key, 0); } + QFont font(const QString& key) const { return m_fonts.value(key); } + + const QString& name() const { return m_name; } + + // Flat name->string map used for QSS {{token}} substitution. Colors are + // "#rrggbb", numbers stringified, font sub-fields as "font.ui.family" etc. + const QHash& qssVars() const { return m_qssVars; } + + // role -> resolved QColor, used to build the QPalette. + const QHash& paletteColors() const { return m_paletteColors; } + +private: + friend class ThemeManager; + + QString m_name; + QHash m_colors; // "bg.window" -> QColor + QHash m_ints; // "radius.sm" / "space.md" / "motion.fast" + QHash m_fonts; // "ui" / "mono" / "title" + QHash m_qssVars; // flat map for template substitution + QHash m_paletteColors; // "window" / "disabled.text" -> QColor +}; diff --git a/src/theme/thememanager.cpp b/src/theme/thememanager.cpp new file mode 100644 index 00000000..b802798d --- /dev/null +++ b/src/theme/thememanager.cpp @@ -0,0 +1,247 @@ +#include "thememanager.h" + +#include "qssbuilder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +// Resolve a JSON string value that may be an "@ref" pointing at another key in +// `colors`. Follows a chain of references with a guard against cycles. +QColor resolveColor(const QString& raw, const QJsonObject& colors) +{ + QString v = raw; + int guard = 0; + while (v.startsWith('@')) { + if (++guard > 32) { + qWarning("ThemeManager: color reference cycle at '%s'", qPrintable(raw)); + return QColor(); + } + const QString ref = v.mid(1); + if (!colors.contains(ref)) { + qWarning("ThemeManager: dangling color reference '@%s'", qPrintable(ref)); + return QColor(); + } + v = colors.value(ref).toString(); + } + QColor c(v); + if (!c.isValid()) + qWarning("ThemeManager: invalid color literal '%s'", qPrintable(v)); + return c; +} + +} // namespace + +ThemeManager& ThemeManager::instance() +{ + static ThemeManager s_instance; + return s_instance; +} + +bool ThemeManager::loadFromResource(const QString& resourcePath) +{ + QFile file(resourcePath); + if (!file.open(QIODevice::ReadOnly)) { + qWarning("ThemeManager: cannot open theme '%s'", qPrintable(resourcePath)); + return false; + } + + QJsonParseError err{}; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) { + qWarning("ThemeManager: JSON parse error in '%s': %s", qPrintable(resourcePath), + qPrintable(err.errorString())); + return false; + } + + const QJsonObject root = doc.object(); + const QJsonObject colors = root.value("color").toObject(); + + Theme t; + t.m_name = root.value("meta").toObject().value("name").toString(); + + // --- colors (resolve @refs) --- + for (auto it = colors.constBegin(); it != colors.constEnd(); ++it) { + const QColor c = resolveColor(it.value().toString(), colors); + t.m_colors.insert(it.key(), c); + t.m_qssVars.insert(it.key(), c.name(QColor::HexRgb)); // "#rrggbb" + } + + // --- scalar groups: radius / space / motion --- + const auto loadInts = [&](const char* group) { + const QJsonObject obj = root.value(group).toObject(); + for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) { + const int val = it.value().toInt(); + const QString flatKey = QString("%1.%2").arg(group, it.key()); + t.m_ints.insert(flatKey, val); + t.m_qssVars.insert(flatKey, QString::number(val)); + } + }; + loadInts("radius"); + loadInts("space"); + loadInts("motion"); + + // --- fonts --- + const QJsonObject fonts = root.value("font").toObject(); + for (auto it = fonts.constBegin(); it != fonts.constEnd(); ++it) { + const QJsonObject f = it.value().toObject(); + const QString family = f.value("family").toString(); + const int size = f.value("size").toInt(12); + const int weight = f.value("weight").toInt(400); + + QFont font; + // Family may be a CSS-style fallback list; take the first as the primary + // and register the rest as substitute candidates via setFamilies. + QStringList families; + for (const QString& part : family.split(',')) + families << part.trimmed(); + if (!families.isEmpty()) { + font.setFamily(families.first()); + font.setFamilies(families); + } + font.setPixelSize(size); + font.setWeight(QFont::Weight(weight)); + t.m_fonts.insert(it.key(), font); + + t.m_qssVars.insert(QString("font.%1.family").arg(it.key()), family); + t.m_qssVars.insert(QString("font.%1.size").arg(it.key()), QString::number(size)); + t.m_qssVars.insert(QString("font.%1.weight").arg(it.key()), QString::number(weight)); + } + + // --- palette role map --- + const QJsonObject palette = root.value("palette").toObject(); + for (auto it = palette.constBegin(); it != palette.constEnd(); ++it) { + t.m_paletteColors.insert(it.key(), resolveColor(it.value().toString(), colors)); + } + + m_theme = t; + return true; +} + +void ThemeManager::setStyleSheetTemplate(const QString& resourcePath) +{ + QFile file(resourcePath); + if (!file.open(QIODevice::ReadOnly)) { + qWarning("ThemeManager: cannot open QSS template '%s'", qPrintable(resourcePath)); + m_qssTemplate.clear(); + return; + } + m_qssTemplate = QString::fromUtf8(file.readAll()); +} + +QPalette ThemeManager::buildPalette() const +{ + const QHash& p = m_theme.paletteColors(); + const auto col = [&](const char* role, QColor fallback) { + return p.value(QString::fromLatin1(role), fallback); + }; + + QPalette pal; + pal.setColor(QPalette::Window, col("window", QColor(53, 53, 53))); + pal.setColor(QPalette::WindowText, col("windowText", Qt::white)); + pal.setColor(QPalette::Base, col("base", QColor(35, 35, 35))); + pal.setColor(QPalette::AlternateBase, col("alternateBase", QColor(53, 53, 53))); + pal.setColor(QPalette::ToolTipBase, col("toolTipBase", QColor(25, 25, 25))); + pal.setColor(QPalette::ToolTipText, col("toolTipText", Qt::white)); + pal.setColor(QPalette::Text, col("text", Qt::white)); + pal.setColor(QPalette::Button, col("button", QColor(53, 53, 53))); + pal.setColor(QPalette::ButtonText, col("buttonText", Qt::white)); + pal.setColor(QPalette::BrightText, col("brightText", Qt::red)); + pal.setColor(QPalette::Link, col("link", QColor(42, 130, 218))); + pal.setColor(QPalette::Highlight, col("highlight", QColor(42, 130, 218))); + pal.setColor(QPalette::HighlightedText, col("highlightedText", Qt::black)); + + const QColor disabled = col("disabled.text", QColor(127, 127, 127)); + pal.setColor(QPalette::Disabled, QPalette::WindowText, + col("disabled.windowText", disabled)); + pal.setColor(QPalette::Disabled, QPalette::Text, disabled); + pal.setColor(QPalette::Disabled, QPalette::ButtonText, + col("disabled.buttonText", disabled)); + pal.setColor(QPalette::Disabled, QPalette::HighlightedText, + col("disabled.highlightedText", disabled)); + pal.setColor(QPalette::Disabled, QPalette::Highlight, + col("disabled.highlight", QColor(80, 80, 80))); + return pal; +} + +void ThemeManager::applyToApplication(QApplication& app) +{ + m_app = &app; + + app.setStyle(QStyleFactory::create("Fusion")); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + app.styleHints()->setColorScheme(Qt::ColorScheme::Dark); +#endif + + reapply(); +} + +void ThemeManager::reapply() +{ + if (!m_app) + return; + + m_app->setPalette(buildPalette()); + m_app->setStyleSheet(QssBuilder::build(m_qssTemplate, m_theme)); + + emit themeChanged(); +} + +void ThemeManager::enableHotReload(const QString& themeFilePath, const QString& qssFilePath) +{ + m_themePath = themeFilePath; + m_qssPath = qssFilePath; + + if (!m_watcher) { + m_watcher = new QFileSystemWatcher(this); + + // Coalesce bursts of change events (editors often fire several per save) + // into a single reload. + m_reloadTimer = new QTimer(this); + m_reloadTimer->setSingleShot(true); + m_reloadTimer->setInterval(120); + connect(m_reloadTimer, &QTimer::timeout, this, &ThemeManager::reloadFromDisk); + connect(m_watcher, &QFileSystemWatcher::fileChanged, this, + [this](const QString&) { m_reloadTimer->start(); }); + } + + // Initial load from the on-disk source, then start watching. + reloadFromDisk(); +} + +void ThemeManager::reloadFromDisk() +{ + // QFile handles plain filesystem paths as well as ":/..." resources. + const bool ok = loadFromResource(m_themePath); // keeps previous theme if parse fails + setStyleSheetTemplate(m_qssPath); + reapply(); + + // --dev-theme feedback. Use fprintf, NOT qInfo/qWarning: the app installs a + // custom Qt message handler that routes logging to Sentry breadcrumbs, which + // would swallow this and defeat the point of a live-tuning loop. + std::fprintf(stderr, "[theme] %s: reloaded from %s\n", + ok ? "ok" : "FAILED (kept previous theme)", qPrintable(m_themePath)); + std::fflush(stderr); + + // Many editors save by writing a temp file and renaming over the original, + // which deletes the inode QFileSystemWatcher was tracking and silently drops + // the watch. Re-add any path the watcher is no longer following. + if (m_watcher) { + const QStringList watched = m_watcher->files(); + for (const QString& p : { m_themePath, m_qssPath }) { + if (!watched.contains(p) && QFile::exists(p)) + m_watcher->addPath(p); + } + } +} diff --git a/src/theme/thememanager.h b/src/theme/thememanager.h new file mode 100644 index 00000000..3f9e5afa --- /dev/null +++ b/src/theme/thememanager.h @@ -0,0 +1,69 @@ +#pragma once + +#include "theme.h" + +#include +#include +#include + +class QApplication; +class QFileSystemWatcher; +class QTimer; + +// Owns the active Theme and applies it to the application. Singleton so paint +// code anywhere can read the current theme and subscribe to themeChanged(). +// +// Phase 0: loads a JSON theme, builds a QPalette identical to the previous +// hand-coded applyDarkTheme(), applies Fusion + palette + (empty) stylesheet. +// Later phases fill in app.qss and thread tokens into custom paint code. +class ThemeManager : public QObject +{ + Q_OBJECT + +public: + static ThemeManager& instance(); + + // Load + resolve a theme JSON (e.g. ":/themes/dark.json"). Returns false and + // keeps the previous theme on parse failure. Does not apply on its own. + bool loadFromResource(const QString& resourcePath); + + // Load the QSS template (e.g. ":/qss/app.qss"). Kept separately so it can be + // re-read on hot-reload without re-parsing the theme. + void setStyleSheetTemplate(const QString& resourcePath); + + const Theme& theme() const { return m_theme; } + + // Apply Fusion style, dark color scheme, the built QPalette, and the built + // stylesheet to the given application. Emits themeChanged(). + void applyToApplication(QApplication& app); + + // Rebuild + reapply palette/stylesheet from the current theme (used after a + // hot-reload). No-op if applyToApplication() was never called. + void reapply(); + + QPalette buildPalette() const; + + // Dev convenience: watch the on-disk *source* theme + QSS files and reload + // live on save (no rebuild needed). Pass real filesystem paths, not ":/..." + // resource paths — the compiled-in resources can't be watched. Does an + // initial load from those paths, so in dev the disk files win over the qrc. + void enableHotReload(const QString& themeFilePath, const QString& qssFilePath); + +signals: + void themeChanged(); + +private: + ThemeManager() = default; + + void reloadFromDisk(); + + Theme m_theme; + QString m_qssTemplate; // raw template text (with {{tokens}}) + QApplication* m_app = nullptr; + + // hot-reload (dev only; null unless enableHotReload() was called) + QFileSystemWatcher* m_watcher = nullptr; + QTimer* m_reloadTimer = nullptr; + QString m_themePath; + QString m_qssPath; +}; diff --git a/src/theme/tokens.h b/src/theme/tokens.h new file mode 100644 index 00000000..5b0aa1f1 --- /dev/null +++ b/src/theme/tokens.h @@ -0,0 +1,44 @@ +#pragma once + +// Canonical token keys. Paint code (node graph, viewports, custom widgets) and +// palette-building should reference these constants instead of hardcoding the +// dotted strings, so a rename is a compile-time break rather than a silent miss. +// +// The string values MUST match the keys under "color" in the theme JSON +// (resources/themes/*.json). +namespace Tokens { + +// --- semantic roles (surface A/B — general chrome) --- +constexpr const char* BgWindow = "bg.window"; +constexpr const char* BgPanel = "bg.panel"; +constexpr const char* BgBase = "bg.base"; +constexpr const char* BgElevated = "bg.elevated"; +constexpr const char* BorderSubtle = "border.subtle"; +constexpr const char* BorderStrong = "border.strong"; +constexpr const char* TextPrimary = "text.primary"; +constexpr const char* TextSecondary = "text.secondary"; +constexpr const char* TextDisabled = "text.disabled"; +constexpr const char* Selection = "selection"; +constexpr const char* Accent = "accent"; + +// --- node graph (surface C) --- +constexpr const char* NodeBg = "node.bg"; +constexpr const char* NodeBgSelected = "node.bg.selected"; +constexpr const char* NodeBorder = "node.border"; +constexpr const char* NodeBorderHover = "node.border.hover"; +constexpr const char* NodeBorderSelect = "node.border.select"; +constexpr const char* NodeTitle = "node.title"; +constexpr const char* Wire = "wire"; +constexpr const char* WireSelected = "wire.selected"; +constexpr const char* PortIn = "port.in"; +constexpr const char* PortOut = "port.out"; +constexpr const char* GridDot = "grid.dot"; +constexpr const char* GridBg = "grid.bg"; +constexpr const char* CheckerA = "checker.a"; +constexpr const char* CheckerB = "checker.b"; + +// --- 3D viewport (surface D) --- +constexpr const char* View3dClear = "view3d.clear"; +constexpr const char* View3dGrid = "view3d.grid"; + +} // namespace Tokens From 0232466ba454f96fd3b0c7b55b27b8ffbd9384cc Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Mon, 27 Jul 2026 13:06:06 -0500 Subject: [PATCH 132/164] add dockmanager style sheet support --- resources/qss/ads.qss.in | 68 +++++++++++++++++++++++++++++++++++ resources/theme.qrc | 1 + src/texturelab/main.cpp | 4 ++- src/texturelab/mainwindow.cpp | 14 ++++++++ src/texturelab/mainwindow.h | 1 + src/theme/thememanager.cpp | 28 ++++++++++++--- src/theme/thememanager.h | 16 +++++++-- 7 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 resources/qss/ads.qss.in diff --git a/resources/qss/ads.qss.in b/resources/qss/ads.qss.in new file mode 100644 index 00000000..e30de7bf --- /dev/null +++ b/resources/qss/ads.qss.in @@ -0,0 +1,68 @@ +/* + * Dock-system (Qt Advanced Docking System) color overrides. + * + * IMPORTANT: this is APPENDED to ADS's own default stylesheet by MainWindow + * (see mainwindow.cpp), not used on its own. ADS's default carries button icons, + * layout metrics, and auto-hide geometry -- we keep all of that and only override + * colors/borders here. Rules must match or exceed the default's selector + * specificity to win; since we're appended, equal specificity is enough. + * + * ADS's default is palette()-driven, so much already follows our theme (e.g. + * highlight == accent). These overrides replace the muddy derived-palette bits + * (inactive tab text = palette(dark), active-tab gradient) with crisp tokens. + * + * Hot-reloads with --dev-theme (edit + save; MainWindow re-applies on themeChanged). + */ + +/* ---- containers & areas ---- */ +ads--CDockContainerWidget { background: {{bg.window}}; } +ads--CDockAreaWidget { background: {{bg.panel}}; } +ads--CDockWidget { background: {{bg.panel}}; border: none; } + +/* Title bar = the strip holding the tabs + area buttons */ +ads--CDockAreaTitleBar { background: {{bg.window}}; border: none; } + +/* ---- dock widget tabs ---- */ +ads--CDockWidgetTab { + background: {{bg.window}}; + border: none; + border-right: 1px solid {{border.subtle}}; + padding: 5px 14px; +} +/* Active tab merges with the content panel below + gets an accent top indicator. + padding-top compensates for the 2px border so the label doesn't shift. */ +ads--CDockWidgetTab[activeTab="true"] { + background: {{bg.panel}}; + border-top: 2px solid {{accent}}; + padding-top: 3px; +} +ads--CDockWidgetTab QLabel { color: {{text.disabled}}; } +ads--CDockWidgetTab[activeTab="true"] QLabel { color: {{text.primary}}; } +ads--CDockWidgetTab:hover QLabel { color: {{text.secondary}}; } + +/* ---- splitters / resize handles ---- */ +ads--CDockSplitter::handle { background: {{border.subtle}}; } +ads--CDockSplitter::handle:hover { background: {{accent}}; } +ads--CResizeHandle { background: {{border.subtle}}; } + +/* ---- title-bar & tab buttons ---- */ +ads--CTitleBarButton { + background: transparent; + border: none; + border-radius: {{radius.sm}}px; + padding: 2px; +} +ads--CTitleBarButton:hover { background: {{ctrl.hover}}; } +ads--CTitleBarButton:pressed { background: {{ctrl.pressed}}; } + +#tabCloseButton:hover { background: {{ctrl.hover}}; border: 1px solid {{border.subtle}}; } +#tabCloseButton:pressed { background: {{ctrl.pressed}}; } + +/* ---- floating docks ---- */ +ads--CFloatingWidgetTitleBar { background: {{bg.window}}; } +#floatingTitleCloseButton:hover, #floatingTitleMaximizeButton:hover { background: {{ctrl.hover}}; } + +/* ---- auto-hide side panels ---- */ +ads--CAutoHideSideBar { background: {{bg.window}}; } +ads--CAutoHideDockContainer { background: {{bg.panel}}; } +#autoHideTitleLabel { color: {{text.secondary}}; } diff --git a/resources/theme.qrc b/resources/theme.qrc index 1fd90802..53d69610 100644 --- a/resources/theme.qrc +++ b/resources/theme.qrc @@ -4,5 +4,6 @@ qss/app.qss.in + qss/ads.qss.in diff --git a/src/texturelab/main.cpp b/src/texturelab/main.cpp index 2151b910..b4f93a0a 100644 --- a/src/texturelab/main.cpp +++ b/src/texturelab/main.cpp @@ -69,6 +69,7 @@ static void applyDarkTheme(QApplication& app) ThemeManager& tm = ThemeManager::instance(); tm.loadFromResource(":/themes/dark.json"); tm.setStyleSheetTemplate(":/qss/app.qss"); + tm.setAdsStyleSheetTemplate(":/qss/ads.qss"); // applied to the dock manager by MainWindow tm.applyToApplication(app); } @@ -114,7 +115,8 @@ int main(int argc, char* argv[]) #ifdef TEXTURELAB_SOURCE_RESOURCES const QString res = QStringLiteral(TEXTURELAB_SOURCE_RESOURCES); ThemeManager::instance().enableHotReload(res + "/themes/dark.json", - res + "/qss/app.qss.in"); + res + "/qss/app.qss.in", + res + "/qss/ads.qss.in"); qInfo("Theme hot-reload enabled, watching %s", qPrintable(res)); #else qWarning("--dev-theme: TEXTURELAB_SOURCE_RESOURCES not compiled in"); diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index befb1183..d693cd16 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -32,6 +32,7 @@ #include "exporter.h" #include "telemetry.h" +#include "thememanager.h" #include "undo/undocommands.h" #include "widgets/aboutdialog.h" #include "widgets/exportdialog.h" @@ -92,6 +93,19 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) this->dockManager = new ads::CDockManager(this); + // Theme the dock system. ADS installs its own default stylesheet on the dock + // manager (constructor -> loadStylesheet), which overrides the global app + // sheet for ads--* widgets. We keep that default (it carries button icons and + // layout metrics) and append our token-driven color overrides. Rebuilt on + // every theme change so it also picks up --dev-theme hot-reloads. + this->adsDefaultStyleSheet = this->dockManager->styleSheet(); + auto applyDockTheme = [this]() { + this->dockManager->setStyleSheet(this->adsDefaultStyleSheet + "\n" + + ThemeManager::instance().adsStyleSheet()); + }; + applyDockTheme(); + connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, applyDockTheme); + this->setupDocks(); // setup callbacks for the widgets that are created once diff --git a/src/texturelab/mainwindow.h b/src/texturelab/mainwindow.h index cb1418bf..0b095e92 100644 --- a/src/texturelab/mainwindow.h +++ b/src/texturelab/mainwindow.h @@ -82,6 +82,7 @@ class MainWindow : public QMainWindow { QUndoStack* undoStack; ads::CDockManager* dockManager; + QString adsDefaultStyleSheet; // ADS's own default sheet, captured before we theme it QMenu* recentFilesMenu; QToolBar* toolBar; QWidget* editor; diff --git a/src/theme/thememanager.cpp b/src/theme/thememanager.cpp index b802798d..5d066346 100644 --- a/src/theme/thememanager.cpp +++ b/src/theme/thememanager.cpp @@ -140,6 +140,22 @@ void ThemeManager::setStyleSheetTemplate(const QString& resourcePath) m_qssTemplate = QString::fromUtf8(file.readAll()); } +void ThemeManager::setAdsStyleSheetTemplate(const QString& resourcePath) +{ + QFile file(resourcePath); + if (!file.open(QIODevice::ReadOnly)) { + qWarning("ThemeManager: cannot open ADS QSS template '%s'", qPrintable(resourcePath)); + m_adsTemplate.clear(); + return; + } + m_adsTemplate = QString::fromUtf8(file.readAll()); +} + +QString ThemeManager::adsStyleSheet() const +{ + return QssBuilder::build(m_adsTemplate, m_theme); +} + QPalette ThemeManager::buildPalette() const { const QHash& p = m_theme.paletteColors(); @@ -198,10 +214,12 @@ void ThemeManager::reapply() emit themeChanged(); } -void ThemeManager::enableHotReload(const QString& themeFilePath, const QString& qssFilePath) +void ThemeManager::enableHotReload(const QString& themeFilePath, const QString& qssFilePath, + const QString& adsFilePath) { m_themePath = themeFilePath; m_qssPath = qssFilePath; + m_adsPath = adsFilePath; if (!m_watcher) { m_watcher = new QFileSystemWatcher(this); @@ -225,7 +243,9 @@ void ThemeManager::reloadFromDisk() // QFile handles plain filesystem paths as well as ":/..." resources. const bool ok = loadFromResource(m_themePath); // keeps previous theme if parse fails setStyleSheetTemplate(m_qssPath); - reapply(); + if (!m_adsPath.isEmpty()) + setAdsStyleSheetTemplate(m_adsPath); + reapply(); // emits themeChanged() -> MainWindow re-applies the ADS sheet // --dev-theme feedback. Use fprintf, NOT qInfo/qWarning: the app installs a // custom Qt message handler that routes logging to Sentry breadcrumbs, which @@ -239,8 +259,8 @@ void ThemeManager::reloadFromDisk() // the watch. Re-add any path the watcher is no longer following. if (m_watcher) { const QStringList watched = m_watcher->files(); - for (const QString& p : { m_themePath, m_qssPath }) { - if (!watched.contains(p) && QFile::exists(p)) + for (const QString& p : { m_themePath, m_qssPath, m_adsPath }) { + if (!p.isEmpty() && !watched.contains(p) && QFile::exists(p)) m_watcher->addPath(p); } } diff --git a/src/theme/thememanager.h b/src/theme/thememanager.h index 3f9e5afa..d6517e89 100644 --- a/src/theme/thememanager.h +++ b/src/theme/thememanager.h @@ -31,6 +31,15 @@ class ThemeManager : public QObject // re-read on hot-reload without re-parsing the theme. void setStyleSheetTemplate(const QString& resourcePath); + // Load the dock-system (ADS) QSS override template. Applied by MainWindow to + // the CDockManager, not to qApp (ADS sets its own sheet on the manager). Kept + // here so it participates in token substitution and hot-reload. + void setAdsStyleSheetTemplate(const QString& resourcePath); + + // Token-substituted ADS override stylesheet. MainWindow appends this to ADS's + // own default sheet. Rebuilds from the current theme on each call. + QString adsStyleSheet() const; + const Theme& theme() const { return m_theme; } // Apply Fusion style, dark color scheme, the built QPalette, and the built @@ -47,7 +56,8 @@ class ThemeManager : public QObject // live on save (no rebuild needed). Pass real filesystem paths, not ":/..." // resource paths — the compiled-in resources can't be watched. Does an // initial load from those paths, so in dev the disk files win over the qrc. - void enableHotReload(const QString& themeFilePath, const QString& qssFilePath); + void enableHotReload(const QString& themeFilePath, const QString& qssFilePath, + const QString& adsFilePath); signals: void themeChanged(); @@ -58,7 +68,8 @@ class ThemeManager : public QObject void reloadFromDisk(); Theme m_theme; - QString m_qssTemplate; // raw template text (with {{tokens}}) + QString m_qssTemplate; // raw app template text (with {{tokens}}) + QString m_adsTemplate; // raw dock-system (ADS) override template QApplication* m_app = nullptr; // hot-reload (dev only; null unless enableHotReload() was called) @@ -66,4 +77,5 @@ class ThemeManager : public QObject QTimer* m_reloadTimer = nullptr; QString m_themePath; QString m_qssPath; + QString m_adsPath; }; From afafdd044de96de2315574df253488c99d62bb8b Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Mon, 27 Jul 2026 14:17:41 -0500 Subject: [PATCH 133/164] style prop widgets --- resources/qss/app.qss.in | 48 ++++++++++++++ resources/themes/dark.json | 14 +++- src/texturelab/mainwindow.cpp | 8 ++- .../widgets/properties/accordionwidget.cpp | 16 +---- .../widgets/properties/curvepropwidget.cpp | 65 +++++++++++-------- .../widgets/properties/curvepropwidget.h | 8 +++ .../widgets/properties/propertieswidget.cpp | 5 +- .../widgets/properties/propwidgets.cpp | 3 +- src/theme/thememanager.cpp | 5 ++ src/theme/thememanager.h | 6 ++ src/theme/tokens.h | 13 ++++ 11 files changed, 144 insertions(+), 47 deletions(-) diff --git a/resources/qss/app.qss.in b/resources/qss/app.qss.in index 2e3baf65..eb5e993d 100644 --- a/resources/qss/app.qss.in +++ b/resources/qss/app.qss.in @@ -270,3 +270,51 @@ QProgressBar { max-height: 14px; } QProgressBar::chunk { background: {{accent}}; border-radius: {{radius.sm}}px; } + +/* ============================================= properties / inspector ===== */ + +/* Collapsible section header (AccordionWidget). + NOTE: min-height MUST be positive. With min-height:0 the flat header button + collapses to ~0px inside PropertiesWidget's dense QVBoxLayout, clipping the + title to invisibility (it survives in a looser layout, which masked it). The + original inline style set no min-height; keep an explicit one here since the + global QPushButton rule's 18px is what we're overriding. */ +#AccordionHeader { + background: {{gray.800}}; + color: {{text.secondary}}; + font-weight: bold; + text-align: left; + padding: 5px 8px; + border: none; + border-top: 1px solid {{border.subtle}}; + border-bottom: 1px solid {{border.subtle}}; + min-height: 20px; +} +#AccordionHeader:hover { background: {{gray.700}}; } + +/* "Frame" / "Comment" section titles in the properties panel */ +#PropSectionTitle { + font-weight: bold; + margin-bottom: 4px; +} + +/* Curve editor readout (In/Out values while dragging) */ +#CurveReadout { + color: {{text.disabled}}; + font-size: 10px; +} + +/* Image property placeholder / preview */ +#ImagePreview { + background: {{bg.input}}; + border: 1px solid {{border.input}}; + border-radius: {{radius.sm}}px; + color: {{text.disabled}}; +} + +/* Compact buttons (e.g. curve "Reset") */ +QPushButton[size="small"] { + font-size: 10px; + padding: 2px 6px; + min-height: 0; +} diff --git a/resources/themes/dark.json b/resources/themes/dark.json index 89157f19..2ae0b573 100644 --- a/resources/themes/dark.json +++ b/resources/themes/dark.json @@ -56,7 +56,19 @@ "checker.b": "@gray.600", "view3d.clear": "@gray.700", - "view3d.grid": "@gray.600" + "view3d.grid": "@gray.600", + + "curve.bg": "@bg.elevated", + "curve.grid": "@gray.850", + "curve.identity": "@gray.800", + "curve.line": "@text.secondary", + "curve.anchor": "@text.disabled", + "curve.anchor.hover": "@text.primary", + "curve.anchor.select": "@accent", + "curve.handle.line": "@gray.600", + "curve.handle.dot": "@text.disabled", + "curve.handle.hover": "@text.secondary", + "curve.handle.corner": "@warn" }, "palette": { diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index d693cd16..159dfba8 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -100,8 +100,14 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) // every theme change so it also picks up --dev-theme hot-reloads. this->adsDefaultStyleSheet = this->dockManager->styleSheet(); auto applyDockTheme = [this]() { + ThemeManager& tm = ThemeManager::instance(); + // Qt prefers an ancestor widget's stylesheet over qApp, so app.qss rules + // (e.g. #AccordionHeader) don't reach widgets inside docks unless we also + // hand them to the dock manager. Order: ADS default -> ADS overrides -> + // app rules (last so our tokens win over ADS's palette()-based defaults). this->dockManager->setStyleSheet(this->adsDefaultStyleSheet + "\n" - + ThemeManager::instance().adsStyleSheet()); + + tm.adsStyleSheet() + "\n" + + tm.appStyleSheet()); }; applyDockTheme(); connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, applyDockTheme); diff --git a/src/texturelab/widgets/properties/accordionwidget.cpp b/src/texturelab/widgets/properties/accordionwidget.cpp index ca0ff62e..1609299d 100644 --- a/src/texturelab/widgets/properties/accordionwidget.cpp +++ b/src/texturelab/widgets/properties/accordionwidget.cpp @@ -13,21 +13,7 @@ AccordionWidget::AccordionWidget(const QString& title, bool startCollapsed, this->setLayout(outerLayout); headerButton = new QPushButton(this); - headerButton->setStyleSheet( - "QPushButton {" - " background: #333333;" - " color: #cccccc;" - " font-weight: bold;" - " font-size: 12px;" - " text-align: left;" - " padding: 5px 8px;" - " border: none;" - " border-top: 1px solid #444444;" - " border-bottom: 1px solid #444444;" - "}" - "QPushButton:hover {" - " background: #3d3d3d;" - "}"); + headerButton->setObjectName("AccordionHeader"); // styled in app.qss.in headerButton->setFlat(true); headerButton->setCursor(Qt::PointingHandCursor); outerLayout->addWidget(headerButton); diff --git a/src/texturelab/widgets/properties/curvepropwidget.cpp b/src/texturelab/widgets/properties/curvepropwidget.cpp index f6226efa..af8e8cef 100644 --- a/src/texturelab/widgets/properties/curvepropwidget.cpp +++ b/src/texturelab/widgets/properties/curvepropwidget.cpp @@ -1,5 +1,8 @@ #include "curvepropwidget.h" +#include "thememanager.h" +#include "tokens.h" + #include #include #include @@ -10,21 +13,9 @@ #include // ============================================================================ -// Colour constants +// Metrics (colours are theme-driven; see CurveCanvas::refreshColors) // ============================================================================ -static const QColor COL_BG { 0x1a, 0x1a, 0x1a }; -static const QColor COL_GRID { 0x25, 0x25, 0x25 }; -static const QColor COL_IDENTITY { 0x30, 0x30, 0x30 }; -static const QColor COL_CURVE { 0xe0, 0xe0, 0xe0 }; -static const QColor COL_ANCHOR_DEF { 0x88, 0x88, 0x88 }; -static const QColor COL_ANCHOR_HOV { 0xff, 0xff, 0xff }; -static const QColor COL_ANCHOR_SEL { 0x4a, 0x9e, 0xff }; -static const QColor COL_HANDLE_LINE{ 0x55, 0x55, 0x55 }; -static const QColor COL_HANDLE_DOT { 0x88, 0x88, 0x88 }; -static const QColor COL_HANDLE_HOV { 0xcc, 0xcc, 0xcc }; -static const QColor COL_HANDLE_COR { 0xff, 0x99, 0x44 }; // corner (broken) mode - static constexpr int CANVAS_PAD = 8; // px padding inside canvas static constexpr float ANCHOR_R = 5.0f; static constexpr float ANCHOR_R_HL = 6.0f; @@ -43,6 +34,28 @@ CurveCanvas::CurveCanvas(QWidget* parent) : QWidget(parent) QSizePolicy sp = sizePolicy(); sp.setHeightForWidth(true); setSizePolicy(sp); + + refreshColors(); + connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, [this]() { + refreshColors(); + update(); + }); +} + +void CurveCanvas::refreshColors() +{ + const Theme& t = ThemeManager::instance().theme(); + colBg = t.color(Tokens::CurveBg); + colGrid = t.color(Tokens::CurveGrid); + colIdentity = t.color(Tokens::CurveIdentity); + colCurve = t.color(Tokens::CurveLine); + colAnchorDef = t.color(Tokens::CurveAnchor); + colAnchorHov = t.color(Tokens::CurveAnchorHover); + colAnchorSel = t.color(Tokens::CurveAnchorSelect); + colHandleLine = t.color(Tokens::CurveHandleLine); + colHandleDot = t.color(Tokens::CurveHandleDot); + colHandleHov = t.color(Tokens::CurveHandleHover); + colHandleCor = t.color(Tokens::CurveHandleCorner); } int CurveCanvas::heightForWidth(int w) const { return w; } @@ -160,9 +173,9 @@ void CurveCanvas::paintEvent(QPaintEvent*) void CurveCanvas::drawGrid(QPainter& p) { - p.fillRect(rect(), COL_BG); + p.fillRect(rect(), colBg); - QPen pen(COL_GRID, 1); + QPen pen(colGrid, 1); p.setPen(pen); for (int i = 0; i <= 4; i++) { @@ -179,7 +192,7 @@ void CurveCanvas::drawGrid(QPainter& p) void CurveCanvas::drawIdentityLine(QPainter& p) { - QPen pen(COL_IDENTITY, 1, Qt::DashLine); + QPen pen(colIdentity, 1, Qt::DashLine); p.setPen(pen); p.drawLine(toWidget(0, 0), toWidget(1, 1)); } @@ -201,7 +214,7 @@ void CurveCanvas::drawCurvePath(QPainter& p) path.cubicTo(cp1, cp2, end); } - QPen pen(COL_CURVE, 1.5f); + QPen pen(colCurve, 1.5f); p.setPen(pen); p.setBrush(Qt::NoBrush); p.drawPath(path); @@ -216,17 +229,17 @@ void CurveCanvas::drawHandles(QPainter& p) QPointF lh = toWidget(pt.x + pt.lx, pt.y + pt.ly); QPointF rh = toWidget(pt.x + pt.rx, pt.y + pt.ry); - QColor dotColor = pt.smooth ? COL_HANDLE_DOT : COL_HANDLE_COR; + QColor dotColor = pt.smooth ? colHandleDot : colHandleCor; // Lines from anchor to handles - QPen linePen(COL_HANDLE_LINE, 1); + QPen linePen(colHandleLine, 1); p.setPen(linePen); p.drawLine(anchor, lh); p.drawLine(anchor, rh); // Handle dots auto drawHandle = [&](QPointF pos, bool isHovered) { - QColor c = isHovered ? COL_HANDLE_HOV : dotColor; + QColor c = isHovered ? colHandleHov : dotColor; p.setPen(QPen(c, 1)); p.setBrush(Qt::NoBrush); p.drawEllipse(pos, HANDLE_R, HANDLE_R); @@ -249,17 +262,17 @@ void CurveCanvas::drawAnchors(QPainter& p) if (i == selectedPoint) { r = ANCHOR_R_HL; - fill = COL_ANCHOR_SEL; + fill = colAnchorSel; // ring - p.setPen(QPen(COL_ANCHOR_SEL, 1)); + p.setPen(QPen(colAnchorSel, 1)); p.setBrush(Qt::NoBrush); p.drawEllipse(wp, r + 2, r + 2); } else if (i == hoveredPoint) { r = ANCHOR_R_HL; - fill = COL_ANCHOR_HOV; + fill = colAnchorHov; } else { r = ANCHOR_R; - fill = COL_ANCHOR_DEF; + fill = colAnchorDef; } p.setPen(Qt::NoPen); @@ -449,7 +462,7 @@ CurvePropWidget::CurvePropWidget(CurveProp* prop, QWidget* parent) resetBtn = new QPushButton("Reset", this); resetBtn->setFixedWidth(50); resetBtn->setFixedHeight(20); - resetBtn->setStyleSheet("font-size: 10px;"); + resetBtn->setProperty("size", "small"); // styled in app.qss.in headerRow->addWidget(label); headerRow->addStretch(); @@ -463,7 +476,7 @@ CurvePropWidget::CurvePropWidget(CurveProp* prop, QWidget* parent) // Readout label readout = new QLabel(this); - readout->setStyleSheet("color: #888; font-size: 10px;"); + readout->setObjectName("CurveReadout"); // styled in app.qss.in readout->setVisible(false); vLayout->addWidget(readout); diff --git a/src/texturelab/widgets/properties/curvepropwidget.h b/src/texturelab/widgets/properties/curvepropwidget.h index be93a5e2..97a59991 100644 --- a/src/texturelab/widgets/properties/curvepropwidget.h +++ b/src/texturelab/widgets/properties/curvepropwidget.h @@ -3,6 +3,7 @@ #include "../../curve.h" #include "../../props.h" +#include #include #include #include @@ -60,6 +61,13 @@ class CurveCanvas : public QWidget { void drawCurvePath(QPainter& p); void drawAnchors(QPainter& p); void drawHandles(QPainter& p); + + // Theme colors, refreshed from the active theme (surface B: QSS can't reach + // QPainter code). Repopulated on construction and on themeChanged(). + void refreshColors(); + QColor colBg, colGrid, colIdentity, colCurve; + QColor colAnchorDef, colAnchorHov, colAnchorSel; + QColor colHandleLine, colHandleDot, colHandleHov, colHandleCor; }; class CurvePropWidget : public QWidget { diff --git a/src/texturelab/widgets/properties/propertieswidget.cpp b/src/texturelab/widgets/properties/propertieswidget.cpp index 14604280..7e49f08c 100644 --- a/src/texturelab/widgets/properties/propertieswidget.cpp +++ b/src/texturelab/widgets/properties/propertieswidget.cpp @@ -11,6 +11,7 @@ PropertiesWidget::PropertiesWidget() : QWidget() { + setObjectName("PropertiesPanel"); // for QSS scoping (app.qss.in) displayMode = PropertyDisplayMode::None; textureChannelProp = new EnumProp(); @@ -269,7 +270,7 @@ void PropertiesWidget::setSelectedFrame(const FramePtr& frame) auto layout = (QVBoxLayout*)this->layout(); auto titleLabel = new QLabel("Frame"); - titleLabel->setStyleSheet("font-weight: bold; margin-bottom: 4px;"); + titleLabel->setObjectName("PropSectionTitle"); // styled in app.qss.in layout->addWidget(titleLabel); auto titleProp = new StringProp(); @@ -331,7 +332,7 @@ void PropertiesWidget::setSelectedComment(const CommentPtr& comment) auto layout = (QVBoxLayout*)this->layout(); auto titleLabel = new QLabel("Comment"); - titleLabel->setStyleSheet("font-weight: bold; margin-bottom: 4px;"); + titleLabel->setObjectName("PropSectionTitle"); // styled in app.qss.in layout->addWidget(titleLabel); auto textProp = new StringProp(); diff --git a/src/texturelab/widgets/properties/propwidgets.cpp b/src/texturelab/widgets/properties/propwidgets.cpp index c1d92222..165bc5f7 100644 --- a/src/texturelab/widgets/properties/propwidgets.cpp +++ b/src/texturelab/widgets/properties/propwidgets.cpp @@ -530,8 +530,7 @@ ImagePropWidget::ImagePropWidget() imagePreview->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); imagePreview->setAlignment(Qt::AlignCenter); imagePreview->setCursor(Qt::PointingHandCursor); - imagePreview->setStyleSheet( - "QLabel { background-color: #333; border: 1px solid #888; }"); + imagePreview->setObjectName("ImagePreview"); // styled in app.qss.in imagePreview->setText("Click to select image"); imagePreview->setScaledContents(false); imagePreview->installEventFilter(this); diff --git a/src/theme/thememanager.cpp b/src/theme/thememanager.cpp index 5d066346..6df8db69 100644 --- a/src/theme/thememanager.cpp +++ b/src/theme/thememanager.cpp @@ -156,6 +156,11 @@ QString ThemeManager::adsStyleSheet() const return QssBuilder::build(m_adsTemplate, m_theme); } +QString ThemeManager::appStyleSheet() const +{ + return QssBuilder::build(m_qssTemplate, m_theme); +} + QPalette ThemeManager::buildPalette() const { const QHash& p = m_theme.paletteColors(); diff --git a/src/theme/thememanager.h b/src/theme/thememanager.h index d6517e89..254cb42e 100644 --- a/src/theme/thememanager.h +++ b/src/theme/thememanager.h @@ -40,6 +40,12 @@ class ThemeManager : public QObject // own default sheet. Rebuilds from the current theme on each call. QString adsStyleSheet() const; + // The built application stylesheet (same one applied to qApp). MainWindow also + // appends this to the dock-manager sheet: Qt prefers an ancestor widget's + // stylesheet over qApp, so without this the app rules don't reach widgets + // living inside ADS docks (e.g. the properties panel). Rebuilds each call. + QString appStyleSheet() const; + const Theme& theme() const { return m_theme; } // Apply Fusion style, dark color scheme, the built QPalette, and the built diff --git a/src/theme/tokens.h b/src/theme/tokens.h index 5b0aa1f1..e6f78a90 100644 --- a/src/theme/tokens.h +++ b/src/theme/tokens.h @@ -41,4 +41,17 @@ constexpr const char* CheckerB = "checker.b"; constexpr const char* View3dClear = "view3d.clear"; constexpr const char* View3dGrid = "view3d.grid"; +// --- curve editor (surface B) --- +constexpr const char* CurveBg = "curve.bg"; +constexpr const char* CurveGrid = "curve.grid"; +constexpr const char* CurveIdentity = "curve.identity"; +constexpr const char* CurveLine = "curve.line"; +constexpr const char* CurveAnchor = "curve.anchor"; +constexpr const char* CurveAnchorHover = "curve.anchor.hover"; +constexpr const char* CurveAnchorSelect = "curve.anchor.select"; +constexpr const char* CurveHandleLine = "curve.handle.line"; +constexpr const char* CurveHandleDot = "curve.handle.dot"; +constexpr const char* CurveHandleHover = "curve.handle.hover"; +constexpr const char* CurveHandleCorner = "curve.handle.corner"; + } // namespace Tokens From 215b736783f47ce50205e434192ce12debd0f510 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Mon, 27 Jul 2026 14:38:34 -0500 Subject: [PATCH 134/164] tidy up library style --- resources/qss/app.qss.in | 31 ++++++++++++++++++++++++ src/texturelab/widgets/librarywidget.cpp | 28 ++++++++++++--------- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/resources/qss/app.qss.in b/resources/qss/app.qss.in index eb5e993d..66023f39 100644 --- a/resources/qss/app.qss.in +++ b/resources/qss/app.qss.in @@ -318,3 +318,34 @@ QPushButton[size="small"] { padding: 2px 6px; min-height: 0; } + +/* ==================================================== library panel ======= */ + +/* Library version indicator; turns warn-colored when the library is outdated */ +#LibraryVersionLabel { color: {{text.secondary}}; } +#LibraryVersionLabel[outdated="true"] { color: {{warn}}; } + +/* Library search: a bit larger/taller than the default input */ +#LibrarySearch { + font-size: 13px; + padding: 4px 6px; + min-height: 24px; +} + +/* Node-thumbnail grid: transparent card that gets an accent ring on hover/select */ +#LibraryList { + background: {{bg.base}}; + border: none; +} +#LibraryList::item { + border: 1px solid transparent; + border-radius: {{radius.sm}}px; + margin-left: 6px; + padding: 2px; +} +#LibraryList::item:hover { border: 1px solid {{accent}}; } +#LibraryList::item:selected { + border: 1px solid {{accent}}; + background: {{ctrl.hover}}; + color: {{text.primary}}; +} diff --git a/src/texturelab/widgets/librarywidget.cpp b/src/texturelab/widgets/librarywidget.cpp index 0fe6d781..6d643236 100644 --- a/src/texturelab/widgets/librarywidget.cpp +++ b/src/texturelab/widgets/librarywidget.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include // https://doc.qt.io/qt-6/qmimedata.html @@ -28,6 +29,7 @@ bool LibraryItemMimeData::hasFormat(const QString& format) const LibraryWidget::LibraryWidget() : QWidget() { + this->setObjectName("LibraryPanel"); // QSS scoping (app.qss.in) this->setMinimumWidth(100); this->setLayout(new QVBoxLayout()); @@ -37,11 +39,13 @@ LibraryWidget::LibraryWidget() : QWidget() versionLayout->setContentsMargins(0, 0, 0, 0); versionLabel = new QLabel(versionRow); + versionLabel->setObjectName("LibraryVersionLabel"); // styled in app.qss.in versionLayout->addWidget(versionLabel); versionLayout->addStretch(); upgradeButton = new QPushButton("Upgrade", versionRow); + upgradeButton->setProperty("variant", "primary"); // draw attention to the action upgradeButton->setVisible(false); connect(upgradeButton, &QPushButton::clicked, this, &LibraryWidget::upgradeRequested); @@ -51,6 +55,7 @@ LibraryWidget::LibraryWidget() : QWidget() // search box searchBar = new QLineEdit(this); + searchBar->setObjectName("LibrarySearch"); searchBar->setPlaceholderText("search"); searchBar->setAlignment(Qt::AlignLeft); connect(searchBar, &QLineEdit::textChanged, @@ -68,14 +73,16 @@ LibraryWidget::LibraryWidget() : QWidget() void LibraryWidget::setLibraryVersion(const QString& version, bool isCurrent) { - if (isCurrent) { - versionLabel->setText(QString("Library: %1").arg(version)); - versionLabel->setStyleSheet(""); - } - else { - versionLabel->setText(QString("Library: %1 (outdated)").arg(version)); - versionLabel->setStyleSheet("color: orange;"); - } + versionLabel->setText(isCurrent + ? QString("Library: %1").arg(version) + : QString("Library: %1 (outdated)").arg(version)); + + // Drive the color from a dynamic property so the "outdated" tint lives in + // app.qss.in (uses the theme's warn token) rather than a hardcoded hex. + versionLabel->setProperty("outdated", !isCurrent); + versionLabel->style()->unpolish(versionLabel); + versionLabel->style()->polish(versionLabel); + upgradeButton->setVisible(!isCurrent); } @@ -158,10 +165,7 @@ LibraryListWidget::LibraryListWidget() : QListWidget() // setAcceptDrops(true); setDropIndicatorShown(true); - setStyleSheet( - "QListView::item{ border-radius: 2px; border: 0px solid rgba(0,0,0,1); " - "margin-left: 6px; }" - "QListView::item:hover{border: 1px solid rgba(50,150,250,1); }"); + setObjectName("LibraryList"); // item styling in app.qss.in } void LibraryListWidget::resizeEvent(QResizeEvent* event) From fc533dd968170e076314fb5db2a7889a6a1bea43 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Mon, 27 Jul 2026 14:57:56 -0500 Subject: [PATCH 135/164] theme nodegraph --- resources/themes/dark.json | 22 +++++++----- src/nodegraph/CMakeLists.txt | 16 +++++---- src/nodegraph/graph/comment.cpp | 13 +++---- src/nodegraph/graph/frame.cpp | 7 ++-- src/nodegraph/graph/nodetheme.h | 24 +++++++++++++ src/nodegraph/graph/scene.cpp | 60 +++++++++++++++++---------------- src/nodegraph/nodegraph.cpp | 25 ++++++++------ src/theme/tokens.h | 12 ++++--- 8 files changed, 112 insertions(+), 67 deletions(-) create mode 100644 src/nodegraph/graph/nodetheme.h diff --git a/resources/themes/dark.json b/resources/themes/dark.json index 2ae0b573..6b90f3f2 100644 --- a/resources/themes/dark.json +++ b/resources/themes/dark.json @@ -40,20 +40,24 @@ "ctrl.pressed": "@gray.550", "ctrl.checked": "@accent", - "node.bg": "@gray.700", - "node.bg.selected": "@gray.600", + "node.bg": "#0A0A0A", "node.border": "@black", "node.border.hover": "@gray.300", "node.border.select": "@gray.200", "node.title": "@white", - "wire": "@gray.200", + "node.channel": "#C8FFC8", + "socket.fill": "#AAAAAA", + "wire": "#AAAAAA", + "wire.dragging": "#969696", "wire.selected": "@accent", - "port.in": "@accent", - "port.out": "@ok", - "grid.dot": "@gray.600", - "grid.bg": "@gray.850", - "checker.a": "@gray.700", - "checker.b": "@gray.600", + "grid.bg": "@gray.700", + "grid.fine": "#3C3C3C", + "grid.coarse": "@gray.900", + "checker.a": "#C0C0C0", + "checker.b": "#808080", + "frame.select": "@warn", + "comment.fill": "@white", + "comment.text": "#F0F0F0", "view3d.clear": "@gray.700", "view3d.grid": "@gray.600", diff --git a/src/nodegraph/CMakeLists.txt b/src/nodegraph/CMakeLists.txt index e165a3ce..5a5ccb3d 100644 --- a/src/nodegraph/CMakeLists.txt +++ b/src/nodegraph/CMakeLists.txt @@ -28,11 +28,13 @@ set(NODEGRAPH_HEADERS # library add_library(nodegraph STATIC ${NODEGRAPH_SRCS} ${NODEGRAPH_HEADERS}) -target_link_libraries(nodegraph PRIVATE Qt${QT_VERSION_MAJOR}::Core - Qt${QT_VERSION_MAJOR}::Gui - Qt${QT_VERSION_MAJOR}::OpenGLWidgets +target_link_libraries(nodegraph PRIVATE Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::OpenGLWidgets Qt${QT_VERSION_MAJOR}::Widgets - OpenGL::GL) + OpenGL::GL + theme) +target_include_directories(nodegraph PRIVATE ${CMAKE_SOURCE_DIR}/src/theme) set_target_properties(nodegraph PROPERTIES @@ -63,8 +65,10 @@ add_executable(nodegraph_app ) target_link_libraries(nodegraph_app PRIVATE Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Gui - Qt${QT_VERSION_MAJOR}::OpenGLWidgets - Qt${QT_VERSION_MAJOR}::Widgets) + Qt${QT_VERSION_MAJOR}::OpenGLWidgets + Qt${QT_VERSION_MAJOR}::Widgets + theme) +target_include_directories(nodegraph_app PRIVATE ${CMAKE_SOURCE_DIR}/src/theme) set_target_properties(nodegraph_app PROPERTIES diff --git a/src/nodegraph/graph/comment.cpp b/src/nodegraph/graph/comment.cpp index 63f4cc35..2733565b 100644 --- a/src/nodegraph/graph/comment.cpp +++ b/src/nodegraph/graph/comment.cpp @@ -1,4 +1,5 @@ #include "comment.h" +#include "nodetheme.h" #include "scene.h" #include #include @@ -63,21 +64,21 @@ void Comment::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, const QRectF rect = calcTextRect(); - // Semi-transparent white background - painter->setBrush(QColor(255, 255, 255, 30)); + // Semi-transparent fill + painter->setBrush(ntColor(Tokens::CommentFill, 30)); painter->setPen(Qt::NoPen); painter->drawRoundedRect(rect, 4, 4); - // White border - QPen borderPen(QColor(255, 255, 255, isSelected() ? 220 : 140), 1.0); + // Border + QPen borderPen(ntColor(Tokens::CommentFill, isSelected() ? 220 : 140), 1.0); painter->setPen(borderPen); painter->setBrush(Qt::NoBrush); painter->drawRoundedRect(rect, 4, 4); - // White text + // Text QFont font("Arial", FONT_SIZE); painter->setFont(font); - painter->setPen(QColor(240, 240, 240)); + painter->setPen(ntColor(Tokens::CommentText)); QFontMetrics fm(font); const QStringList lines = _text.split('\n'); diff --git a/src/nodegraph/graph/frame.cpp b/src/nodegraph/graph/frame.cpp index f46f91a7..e0f5fcff 100644 --- a/src/nodegraph/graph/frame.cpp +++ b/src/nodegraph/graph/frame.cpp @@ -1,4 +1,5 @@ #include "frame.h" +#include "nodetheme.h" #include "scene.h" #include #include @@ -208,18 +209,18 @@ void Frame::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, painter->setFont(font); // shadow pass - painter->setPen(QColor(0, 0, 0, 160)); + painter->setPen(ntColor(Tokens::NodeBorder, 160)); painter->drawText(handleRect.translated(1, 1), Qt::AlignCenter, _title); // text pass - painter->setPen(QColor(255, 255, 255)); + painter->setPen(ntColor(Tokens::NodeTitle)); painter->drawText(handleRect, Qt::AlignCenter, _title); } // Draw frame border QPen borderPen; if (isSelected()) { - borderPen.setColor(QColor(255, 165, 0)); // Orange for selected + borderPen.setColor(ntColor(Tokens::FrameSelect)); // themed "selected" accent borderPen.setWidth(2); } else { diff --git a/src/nodegraph/graph/nodetheme.h b/src/nodegraph/graph/nodetheme.h new file mode 100644 index 00000000..678f9704 --- /dev/null +++ b/src/nodegraph/graph/nodetheme.h @@ -0,0 +1,24 @@ +#pragma once + +// Convenience accessors so the node-graph paint code (surface C: QSS can't reach +// QGraphicsItem painting) can pull colors from the shared theme with one call. +// Colors are read at paint time, so they follow theme changes / --dev-theme +// hot-reload as soon as the view repaints (see NodeGraph's themeChanged hookup). + +#include "thememanager.h" +#include "tokens.h" + +#include + +inline QColor ntColor(const char* token) +{ + return ThemeManager::instance().theme().color(token); +} + +// Same, with an explicit alpha for translucent overlays (socket labels, etc.). +inline QColor ntColor(const char* token, int alpha) +{ + QColor c = ThemeManager::instance().theme().color(token); + c.setAlpha(alpha); + return c; +} diff --git a/src/nodegraph/graph/scene.cpp b/src/nodegraph/graph/scene.cpp index 0e5c278b..b230747b 100644 --- a/src/nodegraph/graph/scene.cpp +++ b/src/nodegraph/graph/scene.cpp @@ -1,6 +1,7 @@ #include "scene.h" #include "comment.h" #include "frame.h" +#include "nodetheme.h" #include #include #include @@ -235,10 +236,11 @@ Node::Node() isHovered = false; showingSocketNames = false; - defaultBorderColor = QColor(0, 0, 0); - highlightBorderColor = QColor(0, 0, 0); - // highlightBorderColor = QColor(120, 120, 120); - selectedBorderColor = QColor(200, 200, 200); + // Border colors are read from tokens at paint time (see Node::paint); these + // members are kept only for any external callers. + defaultBorderColor = ntColor(Tokens::NodeBorder); + highlightBorderColor = ntColor(Tokens::NodeBorderHover); + selectedBorderColor = ntColor(Tokens::NodeBorderSelect); setCacheMode(QGraphicsItem::NoCache); @@ -255,7 +257,7 @@ Node::Node() text->setPos(0, 0); text->setTextWidth(100); - text->setDefaultTextColor(QColor(255, 255, 255)); + text->setDefaultTextColor(ntColor(Tokens::NodeTitle)); text->setZValue(5); // center title @@ -271,7 +273,7 @@ Node::Node() channelText = new QGraphicsTextItem(this); channelText->setFlag(QGraphicsItem::ItemIsFocusable, false); channelText->setFlag(QGraphicsItem::ItemIsSelectable, false); - channelText->setDefaultTextColor(QColor(200, 255, 200)); + channelText->setDefaultTextColor(ntColor(Tokens::NodeChannel)); channelText->setZValue(5); channelText->hide(); QFont chFont = channelText->font(); @@ -482,11 +484,11 @@ void Node::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, QColor borderColor; if (isSelected()) - borderColor = this->selectedBorderColor; + borderColor = ntColor(Tokens::NodeBorderSelect); else if (isHovered) - borderColor = this->highlightBorderColor; + borderColor = ntColor(Tokens::NodeBorderHover); else - borderColor = this->defaultBorderColor; + borderColor = ntColor(Tokens::NodeBorder); // not really needed // painter->setClipRect(option->exposedRect); @@ -518,17 +520,18 @@ void Node::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, bgPath.setFillRule(Qt::WindingFill); bgPath.addRoundedRect(0, 0, nodeWidth, nodeHeight, titleRadius, titleRadius); - painter->fillPath(bgPath, QBrush(QColor(10, 10, 10, 255))); + painter->fillPath(bgPath, QBrush(ntColor(Tokens::NodeBg))); if (!thumbnail.isNull()) { - // Checkerboard background for alpha-transparent thumbnails + // Checkerboard background for alpha-transparent thumbnails. + // (Built once and cached, so it reflects the theme at first draw.) static QPixmap checkerTile; if (checkerTile.isNull()) { checkerTile = QPixmap(16, 16); - checkerTile.fill(QColor(0xC0, 0xC0, 0xC0)); + checkerTile.fill(ntColor(Tokens::CheckerA)); QPainter cp(&checkerTile); - cp.fillRect(0, 0, 8, 8, QColor(0x80, 0x80, 0x80)); - cp.fillRect(8, 8, 8, 8, QColor(0x80, 0x80, 0x80)); + cp.fillRect(0, 0, 8, 8, ntColor(Tokens::CheckerB)); + cp.fillRect(8, 8, 8, 8, ntColor(Tokens::CheckerB)); } painter->fillRect(QRect(0, 0, nodeWidth, nodeHeight), QBrush(checkerTile)); @@ -646,7 +649,7 @@ void Node::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, QPainterPath bgPath; bgPath.setFillRule(Qt::WindingFill); bgPath.addRoundedRect(0, 0, nodeWidth, 18, titleRadius, titleRadius); - painter->fillPath(bgPath, QBrush(QColor(0, 0, 0, 255))); + painter->fillPath(bgPath, QBrush(ntColor(Tokens::NodeBorder))); text->paint(painter, option, widget); } @@ -680,10 +683,10 @@ void Node::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, QRectF bgRect(x, y, rectW, labelH); painter->setPen(Qt::NoPen); - painter->setBrush(QColor(0, 0, 0, 160)); + painter->setBrush(ntColor(Tokens::NodeBorder, 160)); painter->drawRoundedRect(bgRect, 3, 3); - painter->setPen(QColor(255, 255, 255, 220)); + painter->setPen(ntColor(Tokens::NodeTitle, 220)); painter->drawText(bgRect, Qt::AlignCenter, labelName); }; @@ -762,7 +765,7 @@ void Port::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, { auto rect = actualRect(); - QPen pen(QColor(00, 00, 00, 250), 1.0f); + QPen pen(ntColor(Tokens::NodeBorder, 250), 1.0f); painter->setPen(pen); // background @@ -771,10 +774,10 @@ void Port::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, // bgPath.addRoundedRect(-_radius, _radius, rect.width(), rect.height(), // rect.width() / 2, rect.height() / 2); bgPath.addRoundedRect(rect, _radius, _radius); - painter->fillPath(bgPath, QBrush(QColor(170, 170, 170, 255))); + painter->fillPath(bgPath, QBrush(ntColor(Tokens::SocketFill))); // draw border - painter->setPen(QPen(QColor(0, 0, 0), 3)); + painter->setPen(QPen(ntColor(Tokens::NodeBorder), 3)); painter->drawRoundedRect(rect, rect.width() / 2, rect.height() / 2); } @@ -793,8 +796,8 @@ Connection::Connection() connectState = ConnectionState::Complete; - auto pen = QPen(QColor(200, 200, 200)); - pen.setBrush(QColor(50, 150, 250)); + auto pen = QPen(ntColor(Tokens::Wire)); + pen.setBrush(ntColor(Tokens::WireSelected)); pen.setCapStyle(Qt::RoundCap); pen.setWidth(lineThickness); setPen(pen); @@ -830,27 +833,26 @@ void Connection::paint(QPainter* painter, painter->save(); if (connectState == ConnectionState::Dragging) { - QPen pen(QColor(150, 150, 150), lineThickness); + QPen pen(ntColor(Tokens::WireDragging), lineThickness); pen.setStyle(Qt::DashLine); pen.setDashOffset(4); painter->setPen(pen); painter->drawPath(p); - painter->setPen(QPen(QColor(0, 0, 0), 3)); - painter->setBrush(QBrush(QColor(150, 150, 150))); + painter->setPen(QPen(ntColor(Tokens::NodeBorder), 3)); + painter->setBrush(QBrush(ntColor(Tokens::WireDragging))); painter->drawEllipse(pos1, 7, 7); painter->setPen(Qt::NoPen); painter->drawEllipse(pos2, 6, 6); } if (connectState == ConnectionState::Complete) { - // create gradient for line - QPen pen(QColor(170, 170, 170), lineThickness); + QPen pen(ntColor(Tokens::Wire), lineThickness); painter->setPen(pen); painter->drawPath(p); - painter->setPen(QPen(QColor(0, 0, 0), 3)); - painter->setBrush(QBrush(QColor(170, 170, 170))); + painter->setPen(QPen(ntColor(Tokens::NodeBorder), 3)); + painter->setBrush(QBrush(ntColor(Tokens::Wire))); painter->drawEllipse(pos1, 7, 7); painter->drawEllipse(pos2, 7, 7); } diff --git a/src/nodegraph/nodegraph.cpp b/src/nodegraph/nodegraph.cpp index dfffa61e..aeedec09 100644 --- a/src/nodegraph/nodegraph.cpp +++ b/src/nodegraph/nodegraph.cpp @@ -16,12 +16,9 @@ #include #include -const QColor BackgroundColor(53, 53, 53); -const QColor FineGridColor(60, 60, 60); -const QColor CoarseGridColor(25, 25, 25); - #include "graph/comment.h" #include "graph/frame.h" +#include "graph/nodetheme.h" #include "graph/scene.h" #include "nodegraph.h" @@ -48,8 +45,18 @@ NodeGraph::NodeGraph(QWidget* parent) : QGraphicsView(parent) setDragMode(QGraphicsView::RubberBandDrag); setRenderHint(QPainter::Antialiasing); - // setBackgroundBrush(BackgroundColor); - setBackgroundBrush(QColor(53, 53, 53)); + setBackgroundBrush(ntColor(Tokens::GridBg)); + + // Repaint (and refresh the themed background brush) whenever the theme + // changes, so the node graph follows --dev-theme hot-reloads like the rest + // of the app. drawBackground() reads grid colors from tokens at paint time. + QObject::connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, + [this]() { + setBackgroundBrush(ntColor(Tokens::GridBg)); + if (scene()) + scene()->update(); + viewport()->update(); + }); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); @@ -251,14 +258,12 @@ void NodeGraph::drawBackground(QPainter* painter, const QRectF& r) } }; - QBrush bBrush = backgroundBrush(); - - QPen pfine(FineGridColor, 1.0); + QPen pfine(ntColor(Tokens::GridFine), 1.0); painter->setPen(pfine); drawGrid(15); - QPen p(CoarseGridColor, 1.0); + QPen p(ntColor(Tokens::GridCoarse), 1.0); painter->setPen(p); drawGrid(150); diff --git a/src/theme/tokens.h b/src/theme/tokens.h index e6f78a90..5618a3f7 100644 --- a/src/theme/tokens.h +++ b/src/theme/tokens.h @@ -23,19 +23,23 @@ constexpr const char* Accent = "accent"; // --- node graph (surface C) --- constexpr const char* NodeBg = "node.bg"; -constexpr const char* NodeBgSelected = "node.bg.selected"; constexpr const char* NodeBorder = "node.border"; constexpr const char* NodeBorderHover = "node.border.hover"; constexpr const char* NodeBorderSelect = "node.border.select"; constexpr const char* NodeTitle = "node.title"; +constexpr const char* NodeChannel = "node.channel"; +constexpr const char* SocketFill = "socket.fill"; constexpr const char* Wire = "wire"; +constexpr const char* WireDragging = "wire.dragging"; constexpr const char* WireSelected = "wire.selected"; -constexpr const char* PortIn = "port.in"; -constexpr const char* PortOut = "port.out"; -constexpr const char* GridDot = "grid.dot"; constexpr const char* GridBg = "grid.bg"; +constexpr const char* GridFine = "grid.fine"; +constexpr const char* GridCoarse = "grid.coarse"; constexpr const char* CheckerA = "checker.a"; constexpr const char* CheckerB = "checker.b"; +constexpr const char* FrameSelect = "frame.select"; +constexpr const char* CommentFill = "comment.fill"; +constexpr const char* CommentText = "comment.text"; // --- 3D viewport (surface D) --- constexpr const char* View3dClear = "view3d.clear"; From 333b7b85b402a19429c7cbb85f7e9c789208d7eb Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Mon, 27 Jul 2026 15:08:12 -0500 Subject: [PATCH 136/164] style 2d/3d widgets --- resources/qss/app.qss.in | 11 ++++++++ resources/themes/dark.json | 3 ++- src/texturelab/widgets/view2dwidget.cpp | 34 +++++++++++++++++++------ src/theme/tokens.h | 3 +++ src/viewer3d/CMakeLists.txt | 16 +++++++----- src/viewer3d/viewer3d.cpp | 6 ++++- 6 files changed, 57 insertions(+), 16 deletions(-) diff --git a/resources/qss/app.qss.in b/resources/qss/app.qss.in index 66023f39..6aa06ea1 100644 --- a/resources/qss/app.qss.in +++ b/resources/qss/app.qss.in @@ -319,6 +319,17 @@ QPushButton[size="small"] { min-height: 0; } +/* ================================================== viewport overlays ===== */ + +/* 2D view "Texture copied" toast (bottom-center, fades in/out) */ +#ViewToast { + background: {{bg.elevated}}; + color: {{text.primary}}; + border: 1px solid {{border.subtle}}; + padding: 10px 20px; + border-radius: {{radius.md}}px; +} + /* ==================================================== library panel ======= */ /* Library version indicator; turns warn-colored when the library is outdated */ diff --git a/resources/themes/dark.json b/resources/themes/dark.json index 6b90f3f2..4a17013e 100644 --- a/resources/themes/dark.json +++ b/resources/themes/dark.json @@ -59,7 +59,8 @@ "comment.fill": "@white", "comment.text": "#F0F0F0", - "view3d.clear": "@gray.700", + "view2d.bg": "#212121", + "view3d.clear": "#1A1A1A", "view3d.grid": "@gray.600", "curve.bg": "@bg.elevated", diff --git a/src/texturelab/widgets/view2dwidget.cpp b/src/texturelab/widgets/view2dwidget.cpp index e1bf7c14..05b92ca2 100644 --- a/src/texturelab/widgets/view2dwidget.cpp +++ b/src/texturelab/widgets/view2dwidget.cpp @@ -1,5 +1,8 @@ #include "view2dwidget.h" +#include "thememanager.h" +#include "tokens.h" #include +#include #include #include @@ -203,12 +206,7 @@ void View2DWidget::copyTextureToClipboard() void View2DWidget::showToast(const QString& message, int duration) { QLabel* toast = new QLabel(message, this); - toast->setStyleSheet("QLabel {" - " background-color: rgba(50, 50, 50, 200);" - " color: white;" - " padding: 10px 20px;" - " border-radius: 5px;" - "}"); + toast->setObjectName("ViewToast"); // styled in app.qss.in toast->setAlignment(Qt::AlignCenter); toast->adjustSize(); @@ -257,7 +255,15 @@ View2DGraph::View2DGraph(QWidget* parent) : QGraphicsView(parent) setDragMode(QGraphicsView::ScrollHandDrag); setRenderHint(QPainter::Antialiasing); - setBackgroundBrush(QColor(33, 33, 33)); + setBackgroundBrush(ThemeManager::instance().theme().color(Tokens::View2dBg)); + QObject::connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, + [this]() { + setBackgroundBrush( + ThemeManager::instance().theme().color(Tokens::View2dBg)); + if (scene()) + scene()->update(); + viewport()->update(); + }); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); @@ -449,11 +455,13 @@ void NodePreviewGraphicsItem::initializeGL() in vec2 vTexCoord; out vec4 fragColor; uniform sampler2D textureSampler; + uniform vec3 checkerA; + uniform vec3 checkerB; void main() { // 16px checkerboard in screen space vec2 tile = floor(gl_FragCoord.xy / 16.0); float checker = mod(tile.x + tile.y, 2.0); - vec3 bg = mix(vec3(0.753), vec3(0.502), checker); + vec3 bg = mix(checkerA, checkerB, checker); vec4 texColor = texture(textureSampler, vTexCoord); fragColor = vec4(mix(bg, texColor.rgb, texColor.a), 1.0); @@ -604,6 +612,16 @@ void NodePreviewGraphicsItem::paint(QPainter* painter, shaderProgram->bind(); shaderProgram->setUniformValue("projectionMatrix", projectionMatrix); shaderProgram->setUniformValue("textureSampler", 0); + + // Themed checkerboard (matches the node-graph checker); read each paint so it + // follows theme changes / --dev-theme hot-reload. + const Theme& theme = ThemeManager::instance().theme(); + const QColor ca = theme.color(Tokens::CheckerA); + const QColor cb = theme.color(Tokens::CheckerB); + shaderProgram->setUniformValue("checkerA", + QVector3D(ca.redF(), ca.greenF(), ca.blueF())); + shaderProgram->setUniformValue("checkerB", + QVector3D(cb.redF(), cb.greenF(), cb.blueF())); // Bind texture f->glActiveTexture(GL_TEXTURE0); diff --git a/src/theme/tokens.h b/src/theme/tokens.h index 5618a3f7..08bd53b3 100644 --- a/src/theme/tokens.h +++ b/src/theme/tokens.h @@ -41,6 +41,9 @@ constexpr const char* FrameSelect = "frame.select"; constexpr const char* CommentFill = "comment.fill"; constexpr const char* CommentText = "comment.text"; +// --- 2D viewport (surface B/D) --- +constexpr const char* View2dBg = "view2d.bg"; + // --- 3D viewport (surface D) --- constexpr const char* View3dClear = "view3d.clear"; constexpr const char* View3dGrid = "view3d.grid"; diff --git a/src/viewer3d/CMakeLists.txt b/src/viewer3d/CMakeLists.txt index 958296cd..dddc814d 100644 --- a/src/viewer3d/CMakeLists.txt +++ b/src/viewer3d/CMakeLists.txt @@ -39,11 +39,13 @@ set(RESOURCES # library add_library(viewer3d STATIC ${SRCS} ${HEADERS} ${RESOURCES}) -target_link_libraries(viewer3d PRIVATE Qt${QT_VERSION_MAJOR}::Core - Qt${QT_VERSION_MAJOR}::Gui - Qt${QT_VERSION_MAJOR}::OpenGLWidgets +target_link_libraries(viewer3d PRIVATE Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::OpenGLWidgets Qt${QT_VERSION_MAJOR}::Widgets - OpenGL::GL) + OpenGL::GL + theme) +target_include_directories(viewer3d PRIVATE ${CMAKE_SOURCE_DIR}/src/theme) set_target_properties(viewer3d PROPERTIES @@ -75,8 +77,10 @@ add_executable(viewer3d_app ) target_link_libraries(viewer3d_app PRIVATE Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Gui - Qt${QT_VERSION_MAJOR}::OpenGLWidgets - Qt${QT_VERSION_MAJOR}::Widgets) + Qt${QT_VERSION_MAJOR}::OpenGLWidgets + Qt${QT_VERSION_MAJOR}::Widgets + theme) +target_include_directories(viewer3d_app PRIVATE ${CMAKE_SOURCE_DIR}/src/theme) set_target_properties(viewer3d_app PROPERTIES diff --git a/src/viewer3d/viewer3d.cpp b/src/viewer3d/viewer3d.cpp index 485ee759..8e2457f7 100644 --- a/src/viewer3d/viewer3d.cpp +++ b/src/viewer3d/viewer3d.cpp @@ -1,4 +1,6 @@ #include "viewer3d.h" +#include "thememanager.h" +#include "tokens.h" #include #include #include @@ -127,7 +129,9 @@ void Viewer3D::paintGL() // also, the supplied width and height are incorrect // gl->glViewport(0, 0, this->width(), this->height()); gl->glClearDepthf(1.0); - gl->glClearColor(0.1, 0.1, 0.1, 1); + // Themed clear color; read each frame so it follows --dev-theme hot-reload. + const QColor clear = ThemeManager::instance().theme().color(Tokens::View3dClear); + gl->glClearColor(clear.redF(), clear.greenF(), clear.blueF(), 1); gl->glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); gl->glEnable(GL_DEPTH_TEST); From 34abd06b1d84d38f40116b97551b05e6776e0596 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Mon, 27 Jul 2026 15:17:16 -0500 Subject: [PATCH 137/164] tidy up --- resources/qss/app.qss.in | 28 +++++++++++++ src/texturelab/widgets/aboutdialog.cpp | 46 ++++++---------------- src/texturelab/widgets/exportdialog.cpp | 25 +++++------- src/texturelab/widgets/nodesearchpopup.cpp | 5 +-- 4 files changed, 51 insertions(+), 53 deletions(-) diff --git a/resources/qss/app.qss.in b/resources/qss/app.qss.in index 6aa06ea1..366869a2 100644 --- a/resources/qss/app.qss.in +++ b/resources/qss/app.qss.in @@ -330,6 +330,34 @@ QPushButton[size="small"] { border-radius: {{radius.md}}px; } +/* ================================================= popups & dialogs ======= */ + +/* Floating node-search popup (Blender-style quick add). Frameless top-level, so + a crisp border reads as elevation; no radius (avoids frameless-corner artifacts). */ +#NodeSearchPopup { + background: {{bg.elevated}}; + border: 1px solid {{border.strong}}; +} + +/* Export dialog destination field + help text */ +#ExportDestination { + background: {{bg.input}}; + border: 1px solid {{border.input}}; + border-radius: {{radius.sm}}px; + padding: 5px; +} +#ExportDestination[empty="true"] { color: {{text.disabled}}; } +#ExportHelp { color: {{text.disabled}}; } + +/* About dialog typography */ +#AboutTitle { color: {{text.primary}}; font-size: 26px; font-weight: bold; } +#AboutTag { color: {{text.secondary}}; font-size: 12px; } +#AboutVersion { color: {{text.secondary}}; font-size: 13px; } +#AboutDesc { color: {{text.secondary}}; font-size: 13px; } +#AboutLink { font-size: 12px; } +#AboutCopyright { color: {{text.disabled}}; font-size: 11px; } +#AboutSeparator { color: {{border.subtle}}; } + /* ==================================================== library panel ======= */ /* Library version indicator; turns warn-colored when the library is outdated */ diff --git a/src/texturelab/widgets/aboutdialog.cpp b/src/texturelab/widgets/aboutdialog.cpp index 6b4feef2..989de8e2 100644 --- a/src/texturelab/widgets/aboutdialog.cpp +++ b/src/texturelab/widgets/aboutdialog.cpp @@ -20,30 +20,8 @@ AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent) void AboutDialog::setupUI() { - setStyleSheet(R"( - QDialog { - background-color: #1e1e1e; - color: #e0e0e0; - } - QLabel { - color: #e0e0e0; - background: transparent; - } - QPushButton#closeBtn { - background-color: #3a3a3a; - color: #e0e0e0; - border: none; - border-radius: 4px; - padding: 6px 20px; - font-size: 13px; - } - QPushButton#closeBtn:hover { - background-color: #4a4a4a; - } - QPushButton#closeBtn:pressed { - background-color: #2a2a2a; - } - )"); + // Base widget/dialog/button styling comes from the global theme (app.qss.in); + // only the About-specific label typography is set here (see #About* rules). auto outerLayout = new QVBoxLayout(this); outerLayout->setContentsMargins(0, 0, 0, 0); @@ -52,7 +30,6 @@ void AboutDialog::setupUI() // Header band auto header = new QWidget(); header->setFixedHeight(110); - header->setStyleSheet("background: transparent;"); auto headerLayout = new QHBoxLayout(header); headerLayout->setContentsMargins(28, 0, 28, 0); @@ -71,12 +48,11 @@ void AboutDialog::setupUI() titleBlock->setSpacing(4); auto nameLabel = new QLabel("TextureLab"); - nameLabel->setStyleSheet( - "color: #ffffff; font-size: 26px; font-weight: bold;"); + nameLabel->setObjectName("AboutTitle"); titleBlock->addWidget(nameLabel); auto tagLabel = new QLabel("Procedural Texture Authoring"); - tagLabel->setStyleSheet("color: #a0b4d0; font-size: 12px;"); + tagLabel->setObjectName("AboutTag"); titleBlock->addWidget(tagLabel); headerLayout->addLayout(titleBlock); @@ -92,32 +68,32 @@ void AboutDialog::setupUI() QString version = QCoreApplication::applicationVersion(); auto versionLabel = new QLabel(QString("Version %1").arg(version)); - versionLabel->setStyleSheet("font-size: 13px; color: #b0b0b0;"); + versionLabel->setObjectName("AboutVersion"); bodyLayout->addWidget(versionLabel); auto separator = new QFrame(); + separator->setObjectName("AboutSeparator"); separator->setFrameShape(QFrame::HLine); - separator->setStyleSheet("color: #333333;"); bodyLayout->addWidget(separator); auto descLabel = new QLabel( "A node-based texture creation tool for game artists and developers."); descLabel->setWordWrap(true); - descLabel->setStyleSheet("font-size: 13px; color: #c0c0c0; line-height: 1.4;"); + descLabel->setObjectName("AboutDesc"); bodyLayout->addWidget(descLabel); auto linkLabel = new QLabel( - "github.com/njbrown/texturelab"); + "" + "github.com/njbrown/texturelab"); linkLabel->setOpenExternalLinks(true); - linkLabel->setStyleSheet("font-size: 12px;"); + linkLabel->setObjectName("AboutLink"); bodyLayout->addWidget(linkLabel); bodyLayout->addStretch(); auto footerLayout = new QHBoxLayout(); auto copyrightLabel = new QLabel("© Nicolas Brown"); - copyrightLabel->setStyleSheet("font-size: 11px; color: #666666;"); + copyrightLabel->setObjectName("AboutCopyright"); footerLayout->addWidget(copyrightLabel); footerLayout->addStretch(); diff --git a/src/texturelab/widgets/exportdialog.cpp b/src/texturelab/widgets/exportdialog.cpp index b46d0aaa..a1c279e6 100644 --- a/src/texturelab/widgets/exportdialog.cpp +++ b/src/texturelab/widgets/exportdialog.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include ExportDialog::ExportDialog(QWidget* parent) : QDialog(parent) @@ -50,8 +51,7 @@ void ExportDialog::setupUI() auto destLayout = new QHBoxLayout(); destinationLabel = new QLabel("No destination selected"); - destinationLabel->setStyleSheet("QLabel { padding: 5px; border-radius: " - "3px; }"); + destinationLabel->setObjectName("ExportDestination"); // styled in app.qss.in destinationLabel->setWordWrap(true); destLayout->addWidget(destinationLabel, 1); @@ -81,7 +81,7 @@ void ExportDialog::setupUI() auto helpLabel = new QLabel( "${project} - Project Name
${name} - Output Node " "Name
"); - helpLabel->setStyleSheet("QLabel { color: #999; }"); + helpLabel->setObjectName("ExportHelp"); // styled in app.qss.in mainLayout->addWidget(helpLabel); // Spacer @@ -105,18 +105,13 @@ void ExportDialog::setupUI() void ExportDialog::updateDestinationDisplay() { - if (exportDestination.isEmpty()) { - destinationLabel->setText("No destination selected"); - destinationLabel->setStyleSheet("QLabel { padding: 5px; border-radius: " - "3px; color: #999; }"); - chooseDestinationBtn->setText("Choose Folder"); - } - else { - destinationLabel->setText(exportDestination); - destinationLabel->setStyleSheet("QLabel { padding: 5px; border-radius: " - "3px; }"); - chooseDestinationBtn->setText("..."); - } + const bool empty = exportDestination.isEmpty(); + destinationLabel->setText(empty ? "No destination selected" : exportDestination); + // "empty" drives the muted color via app.qss.in (#ExportDestination[empty="true"]) + destinationLabel->setProperty("empty", empty); + destinationLabel->style()->unpolish(destinationLabel); + destinationLabel->style()->polish(destinationLabel); + chooseDestinationBtn->setText(empty ? "Choose Folder" : "..."); } void ExportDialog::onChooseDestination() diff --git a/src/texturelab/widgets/nodesearchpopup.cpp b/src/texturelab/widgets/nodesearchpopup.cpp index 48bff934..b7907809 100644 --- a/src/texturelab/widgets/nodesearchpopup.cpp +++ b/src/texturelab/widgets/nodesearchpopup.cpp @@ -13,10 +13,9 @@ NodeSearchPopup::NodeSearchPopup(QWidget* parent) : QFrame(parent) { library = nullptr; - // Setup frame styling for a floating popup + // Setup frame styling for a floating popup (see #NodeSearchPopup in app.qss.in) + setObjectName("NodeSearchPopup"); setWindowFlags(Qt::Popup | Qt::FramelessWindowHint); - setFrameStyle(QFrame::StyledPanel | QFrame::Raised); - setLineWidth(2); // Set fixed size for the popup setFixedSize(300, 400); From 8327cc05c843be333abb0d6f2e19937894cbfc87 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Mon, 27 Jul 2026 18:45:16 -0500 Subject: [PATCH 138/164] ui tidy up and build script style check --- .github/workflows/build.yml | 10 +++ scripts/check-theme-hygiene.sh | 62 +++++++++++++++++++ src/nodegraph/graph/frame.cpp | 2 +- src/nodegraph/graph/scene.cpp | 2 +- src/texturelab/mainwindow.cpp | 6 +- .../widgets/properties/propwidgets.cpp | 4 +- 6 files changed, 79 insertions(+), 7 deletions(-) create mode 100755 scripts/check-theme-hygiene.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cd3b2f55..d34bad68 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,6 +33,16 @@ jobs: SUFFIX=$(grep -oP 'set\(TEXTURELAB_VERSION_SUFFIX "\K[^"]*' src/texturelab/CMakeLists.txt) echo "tag=v${NUM}${SUFFIX}-$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + # Fails the build if inline widget stylesheets or hardcoded QColor literals + # creep into the UI/rendering code (they'd bypass the theme system and break + # --dev-theme hot-reload). See scripts/check-theme-hygiene.sh + UI_DESIGN_SYSTEM_PRD.md. + theme-hygiene: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check theme hygiene + run: bash scripts/check-theme-hygiene.sh + build-linux: needs: version # runs-on: ubuntu-20.04 diff --git a/scripts/check-theme-hygiene.sh b/scripts/check-theme-hygiene.sh new file mode 100755 index 00000000..395ab681 --- /dev/null +++ b/scripts/check-theme-hygiene.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Theme hygiene gate. +# +# The theme system (src/theme/ + resources/themes/*.json + resources/qss/*.qss.in) +# is the single source of truth for the app's look. This check fails the build if +# new *inline widget stylesheets* or *hardcoded QColor literals* creep into the +# UI / rendering code, which would bypass the theme and break --dev-theme +# hot-reload. +# +# Legitimate exceptions carry a "// theme-exempt: " marker on the same +# line (e.g. dynamic color-DATA swatches, or the one call that applies the +# composed theme sheet to the ADS dock manager). See UI_DESIGN_SYSTEM_PRD.md. +# +# Out of scope by design: src/theme (the system), src/ads (vendored submodule), +# src/colorpicker (a color-DATA widget lib), src/texturelab/libraries (node +# default *values*, not UI styling). + +set -uo pipefail +cd "$(dirname "$0")/.." + +# UI + rendering code that must stay theme-driven. +SCOPE=( + "src/texturelab/widgets" + "src/texturelab/mainwindow.cpp" + "src/nodegraph/graph" +) + +status=0 + +# Drop matches that live on a commented-out line (content after "file:line:" +# starts with //) and any line carrying the theme-exempt marker. +drop_noise() { grep -vE ':[0-9]+:[[:space:]]*//' | grep -v 'theme-exempt'; } + +# 1) Inline widget stylesheets -> belong in resources/qss/app.qss.in. +hits=$(grep -rnE '(->|\.)setStyleSheet\(' "${SCOPE[@]}" --include=*.cpp 2>/dev/null \ + | drop_noise) +if [ -n "$hits" ]; then + echo "FAIL: inline setStyleSheet() in UI code." + echo " Move the rule to resources/qss/app.qss.in and target it by objectName," + echo " or add '// theme-exempt: ' if it is genuinely dynamic data." + echo "$hits" | sed 's/^/ /' + echo + status=1 +fi + +# 2) Hardcoded numeric QColor literals in paint code -> use a token. +hits=$(grep -rnE 'QColor\((0x)?[0-9]' "${SCOPE[@]}" --include=*.cpp 2>/dev/null \ + | drop_noise) +if [ -n "$hits" ]; then + echo "FAIL: hardcoded QColor(...) literal in rendering code." + echo " Add a token to resources/themes/dark.json + src/theme/tokens.h and read it" + echo " via ntColor()/ThemeManager::instance().theme().color(), or mark // theme-exempt." + echo "$hits" | sed 's/^/ /' + echo + status=1 +fi + +if [ "$status" -eq 0 ]; then + echo "theme hygiene: OK — no un-exempted inline stylesheets or hardcoded colors in UI code." +fi +exit $status diff --git a/src/nodegraph/graph/frame.cpp b/src/nodegraph/graph/frame.cpp index e0f5fcff..0de12312 100644 --- a/src/nodegraph/graph/frame.cpp +++ b/src/nodegraph/graph/frame.cpp @@ -237,7 +237,7 @@ void Frame::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, // Draw resize handles when selected if (isSelected()) { - painter->setBrush(QColor(100, 100, 100, 100)); + painter->setBrush(QColor(100, 100, 100, 100)); // theme-exempt: neutral resize-handle overlay painter->setPen(Qt::NoPen); qreal h = RESIZE_HANDLE_SIZE; diff --git a/src/nodegraph/graph/scene.cpp b/src/nodegraph/graph/scene.cpp index b230747b..367ece96 100644 --- a/src/nodegraph/graph/scene.cpp +++ b/src/nodegraph/graph/scene.cpp @@ -284,7 +284,7 @@ Node::Node() effect->setBlurRadius(20); effect->setXOffset(0); effect->setYOffset(0); - effect->setColor(QColor(00, 00, 00, 70)); + effect->setColor(QColor(00, 00, 00, 70)); // theme-exempt: unused shadow effect (setGraphicsEffect disabled) // setGraphicsEffect(effect); // forces node to raster remder // maybe render to node behind this to get same effect diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 159dfba8..72e0c086 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -105,9 +105,9 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) // (e.g. #AccordionHeader) don't reach widgets inside docks unless we also // hand them to the dock manager. Order: ADS default -> ADS overrides -> // app rules (last so our tokens win over ADS's palette()-based defaults). - this->dockManager->setStyleSheet(this->adsDefaultStyleSheet + "\n" - + tm.adsStyleSheet() + "\n" - + tm.appStyleSheet()); + const QString sheet = this->adsDefaultStyleSheet + "\n" + tm.adsStyleSheet() + + "\n" + tm.appStyleSheet(); + this->dockManager->setStyleSheet(sheet); // theme-exempt: applies composed theme sheet }; applyDockTheme(); connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, applyDockTheme); diff --git a/src/texturelab/widgets/properties/propwidgets.cpp b/src/texturelab/widgets/properties/propwidgets.cpp index 165bc5f7..1a0e3989 100644 --- a/src/texturelab/widgets/properties/propwidgets.cpp +++ b/src/texturelab/widgets/properties/propwidgets.cpp @@ -372,7 +372,7 @@ void ColorPropWidget::updateColorPreview() .arg(prop->value.green()) .arg(prop->value.blue()) .arg(prop->value.alpha()); - colorPreview->setStyleSheet(styleSheet); + colorPreview->setStyleSheet(styleSheet); // theme-exempt: dynamic color-data swatch } } @@ -452,7 +452,7 @@ void GradientPropWidget::updateGradientPreview() painter.end(); QString styleSheet = QString("border: 1px solid #888;"); - gradientPreview->setStyleSheet(styleSheet); + gradientPreview->setStyleSheet(styleSheet); // theme-exempt: dynamic gradient-data swatch // Set as background using palette palette.setBrush(gradientPreview->backgroundRole(), QBrush(pixmap)); From d4a94142bb99d63ae42825ddda4bc161151906ff Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Mon, 27 Jul 2026 19:23:49 -0500 Subject: [PATCH 139/164] icon tidy up --- resources/icons/ads/close-button-disabled.svg | 139 ++++++++++++ resources/icons/ads/close-button.svg | 139 ++++++++++++ .../icons/ads/detach-button-disabled.svg | 205 ++++++++++++++++++ resources/icons/ads/detach-button.svg | 175 +++++++++++++++ resources/icons/ads/maximize-button.svg | 145 +++++++++++++ .../icons/ads/minimize-button-focused.svg | 2 + resources/icons/ads/restore-button.svg | 150 +++++++++++++ resources/icons/ads/tabs-menu-button.svg | 138 ++++++++++++ .../icons/ads/vs-pin-button-disabled.svg | 2 + .../ads/vs-pin-button-pinned-focused.svg | 2 + resources/icons/ads/vs-pin-button.svg | 2 + resources/qss/ads.qss.in | 47 +++- resources/qss/app.qss.in | 4 + resources/theme.qrc | 13 ++ src/icons/copy.svg | 2 +- src/icons/crosshair.svg | 2 +- src/icons/export.svg | 5 + src/icons/grid.svg | 2 +- src/icons/redo.svg | 4 + src/icons/save.svg | 2 +- src/icons/undo.svg | 4 + src/texturelab/assets.qrc | 3 + src/texturelab/mainwindow.cpp | 13 +- src/texturelab/widgets/view2dwidget.cpp | 5 +- 24 files changed, 1189 insertions(+), 16 deletions(-) create mode 100644 resources/icons/ads/close-button-disabled.svg create mode 100644 resources/icons/ads/close-button.svg create mode 100644 resources/icons/ads/detach-button-disabled.svg create mode 100644 resources/icons/ads/detach-button.svg create mode 100644 resources/icons/ads/maximize-button.svg create mode 100644 resources/icons/ads/minimize-button-focused.svg create mode 100644 resources/icons/ads/restore-button.svg create mode 100644 resources/icons/ads/tabs-menu-button.svg create mode 100644 resources/icons/ads/vs-pin-button-disabled.svg create mode 100644 resources/icons/ads/vs-pin-button-pinned-focused.svg create mode 100644 resources/icons/ads/vs-pin-button.svg create mode 100644 src/icons/export.svg create mode 100644 src/icons/redo.svg create mode 100644 src/icons/undo.svg diff --git a/resources/icons/ads/close-button-disabled.svg b/resources/icons/ads/close-button-disabled.svg new file mode 100644 index 00000000..fb0cb58c --- /dev/null +++ b/resources/icons/ads/close-button-disabled.svg @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Jemis Mali + + + + + image/svg+xml + + + + + + diff --git a/resources/icons/ads/close-button.svg b/resources/icons/ads/close-button.svg new file mode 100644 index 00000000..6ebbf382 --- /dev/null +++ b/resources/icons/ads/close-button.svg @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Jemis Mali + + + + + image/svg+xml + + + + + + diff --git a/resources/icons/ads/detach-button-disabled.svg b/resources/icons/ads/detach-button-disabled.svg new file mode 100644 index 00000000..b94b0c3a --- /dev/null +++ b/resources/icons/ads/detach-button-disabled.svg @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Jemis Mali + + + + + image/svg+xml + + + + + + + + diff --git a/resources/icons/ads/detach-button.svg b/resources/icons/ads/detach-button.svg new file mode 100644 index 00000000..a1b6241c --- /dev/null +++ b/resources/icons/ads/detach-button.svg @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Jemis Mali + + + + + image/svg+xml + + + + + + + diff --git a/resources/icons/ads/maximize-button.svg b/resources/icons/ads/maximize-button.svg new file mode 100644 index 00000000..4dc165eb --- /dev/null +++ b/resources/icons/ads/maximize-button.svg @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Jemis Mali + + + + + image/svg+xml + + + + + + diff --git a/resources/icons/ads/minimize-button-focused.svg b/resources/icons/ads/minimize-button-focused.svg new file mode 100644 index 00000000..c473a133 --- /dev/null +++ b/resources/icons/ads/minimize-button-focused.svg @@ -0,0 +1,2 @@ + + diff --git a/resources/icons/ads/restore-button.svg b/resources/icons/ads/restore-button.svg new file mode 100644 index 00000000..d6fd5059 --- /dev/null +++ b/resources/icons/ads/restore-button.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Jemis Mali + + + + + image/svg+xml + + + + + + diff --git a/resources/icons/ads/tabs-menu-button.svg b/resources/icons/ads/tabs-menu-button.svg new file mode 100644 index 00000000..d5e2e2b4 --- /dev/null +++ b/resources/icons/ads/tabs-menu-button.svg @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Jemis Mali + + + + + image/svg+xml + + + + + + diff --git a/resources/icons/ads/vs-pin-button-disabled.svg b/resources/icons/ads/vs-pin-button-disabled.svg new file mode 100644 index 00000000..a00d2b50 --- /dev/null +++ b/resources/icons/ads/vs-pin-button-disabled.svg @@ -0,0 +1,2 @@ + + diff --git a/resources/icons/ads/vs-pin-button-pinned-focused.svg b/resources/icons/ads/vs-pin-button-pinned-focused.svg new file mode 100644 index 00000000..ac05edb9 --- /dev/null +++ b/resources/icons/ads/vs-pin-button-pinned-focused.svg @@ -0,0 +1,2 @@ + + diff --git a/resources/icons/ads/vs-pin-button.svg b/resources/icons/ads/vs-pin-button.svg new file mode 100644 index 00000000..f9e36d01 --- /dev/null +++ b/resources/icons/ads/vs-pin-button.svg @@ -0,0 +1,2 @@ + + diff --git a/resources/qss/ads.qss.in b/resources/qss/ads.qss.in index e30de7bf..bc813141 100644 --- a/resources/qss/ads.qss.in +++ b/resources/qss/ads.qss.in @@ -16,15 +16,18 @@ /* ---- containers & areas ---- */ ads--CDockContainerWidget { background: {{bg.window}}; } -ads--CDockAreaWidget { background: {{bg.panel}}; } +/* Strong frame around each panel (DaVinci Resolve-style hard separation). */ +ads--CDockAreaWidget { background: {{bg.panel}}; border: 1px solid {{border.strong}}; } ads--CDockWidget { background: {{bg.panel}}; border: none; } -/* Title bar = the strip holding the tabs + area buttons */ -ads--CDockAreaTitleBar { background: {{bg.window}}; border: none; } +/* Title bar = the strip holding the tabs + area buttons. Darker than the panel + so the active tab (panel-colored) reads as raised out of a near-black header. */ +ads--CDockAreaTitleBar { background: {{bg.elevated}}; border: none; } /* ---- dock widget tabs ---- */ +/* Inactive tabs sit in the dark header. */ ads--CDockWidgetTab { - background: {{bg.window}}; + background: {{bg.elevated}}; border: none; border-right: 1px solid {{border.subtle}}; padding: 5px 14px; @@ -41,9 +44,10 @@ ads--CDockWidgetTab[activeTab="true"] QLabel { color: {{text.primary}}; } ads--CDockWidgetTab:hover QLabel { color: {{text.secondary}}; } /* ---- splitters / resize handles ---- */ -ads--CDockSplitter::handle { background: {{border.subtle}}; } +/* Strong (black) gutters between panels to match the panel frames. */ +ads--CDockSplitter::handle { background: {{border.strong}}; } ads--CDockSplitter::handle:hover { background: {{accent}}; } -ads--CResizeHandle { background: {{border.subtle}}; } +ads--CResizeHandle { background: {{border.strong}}; } /* ---- title-bar & tab buttons ---- */ ads--CTitleBarButton { @@ -63,6 +67,35 @@ ads--CFloatingWidgetTitleBar { background: {{bg.window}}; } #floatingTitleCloseButton:hover, #floatingTitleMaximizeButton:hover { background: {{ctrl.hover}}; } /* ---- auto-hide side panels ---- */ -ads--CAutoHideSideBar { background: {{bg.window}}; } +ads--CAutoHideSideBar { background: {{bg.elevated}}; } ads--CAutoHideDockContainer { background: {{bg.panel}}; } #autoHideTitleLabel { color: {{text.secondary}}; } + +/* ---- white icons ---- (override ADS's black default SVGs; see resources/icons/ads/, + generated white/gray copies. Static URLs, no token substitution.) */ +#tabCloseButton { + qproperty-icon: url(:/adsicons/close-button.svg), + url(:/adsicons/close-button-disabled.svg) disabled; +} +#dockAreaCloseButton { + qproperty-icon: url(:/adsicons/close-button.svg), + url(:/adsicons/close-button-disabled.svg) disabled; +} +#tabsMenuButton { qproperty-icon: url(:/adsicons/tabs-menu-button.svg); } +#detachGroupButton { + qproperty-icon: url(:/adsicons/detach-button.svg), + url(:/adsicons/detach-button-disabled.svg) disabled; +} +#floatingTitleCloseButton { qproperty-icon: url(:/adsicons/close-button.svg); } +#dockAreaMinimizeButton { qproperty-icon: url(:/adsicons/minimize-button-focused.svg); } +#dockAreaAutoHideButton { + qproperty-icon: url(:/adsicons/vs-pin-button.svg), + url(:/adsicons/vs-pin-button-disabled.svg) disabled; +} +ads--CAutoHideDockContainer #dockAreaAutoHideButton { + qproperty-icon: url(:/adsicons/vs-pin-button-pinned-focused.svg); +} +ads--CFloatingWidgetTitleBar { + qproperty-maximizeIcon: url(:/adsicons/maximize-button.svg); + qproperty-normalIcon: url(:/adsicons/restore-button.svg); +} diff --git a/resources/qss/app.qss.in b/resources/qss/app.qss.in index 366869a2..8890c3c8 100644 --- a/resources/qss/app.qss.in +++ b/resources/qss/app.qss.in @@ -321,6 +321,10 @@ QPushButton[size="small"] { /* ================================================== viewport overlays ===== */ +/* Compact 2D-view toolbar (save/copy/tile/recenter) */ +#View2DToolbar { padding: 1px; spacing: 1px; } +#View2DToolbar QToolButton { padding: 2px; } + /* 2D view "Texture copied" toast (bottom-center, fades in/out) */ #ViewToast { background: {{bg.elevated}}; diff --git a/resources/theme.qrc b/resources/theme.qrc index 53d69610..d7f39548 100644 --- a/resources/theme.qrc +++ b/resources/theme.qrc @@ -6,4 +6,17 @@ qss/app.qss.in qss/ads.qss.in + + icons/ads/close-button.svg + icons/ads/close-button-disabled.svg + icons/ads/tabs-menu-button.svg + icons/ads/detach-button.svg + icons/ads/detach-button-disabled.svg + icons/ads/minimize-button-focused.svg + icons/ads/maximize-button.svg + icons/ads/restore-button.svg + icons/ads/vs-pin-button.svg + icons/ads/vs-pin-button-disabled.svg + icons/ads/vs-pin-button-pinned-focused.svg + diff --git a/src/icons/copy.svg b/src/icons/copy.svg index fa72d79c..f0f2c154 100644 --- a/src/icons/copy.svg +++ b/src/icons/copy.svg @@ -1,4 +1,4 @@ - + diff --git a/src/icons/crosshair.svg b/src/icons/crosshair.svg index ef26448c..df903263 100644 --- a/src/icons/crosshair.svg +++ b/src/icons/crosshair.svg @@ -1,4 +1,4 @@ - + diff --git a/src/icons/export.svg b/src/icons/export.svg new file mode 100644 index 00000000..9effdc40 --- /dev/null +++ b/src/icons/export.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/icons/grid.svg b/src/icons/grid.svg index 39ccf3f0..6a93782f 100644 --- a/src/icons/grid.svg +++ b/src/icons/grid.svg @@ -1,4 +1,4 @@ - + diff --git a/src/icons/redo.svg b/src/icons/redo.svg new file mode 100644 index 00000000..501cd955 --- /dev/null +++ b/src/icons/redo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/save.svg b/src/icons/save.svg index db3c6667..1b8f48fc 100644 --- a/src/icons/save.svg +++ b/src/icons/save.svg @@ -1,4 +1,4 @@ - + diff --git a/src/icons/undo.svg b/src/icons/undo.svg new file mode 100644 index 00000000..1a562277 --- /dev/null +++ b/src/icons/undo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/texturelab/assets.qrc b/src/texturelab/assets.qrc index eed61479..02ac8a35 100644 --- a/src/texturelab/assets.qrc +++ b/src/texturelab/assets.qrc @@ -94,6 +94,9 @@ ../icons/grid.svg ../icons/crosshair.svg ../icons/copy.svg + ../icons/undo.svg + ../icons/redo.svg + ../icons/export.svg ../icons/logo.png diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 72e0c086..5633f724 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -493,13 +493,19 @@ void MainWindow::setupToolbar() { // https://www.setnode.com/blog/right-aligning-a-button-in-a-qtoolbar/ toolBar = this->addToolBar("main toolbar"); + toolBar->setIconSize(QSize(18, 18)); + toolBar->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); QWidget* spacer = new QWidget(); spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - // undo redo — reuse the same actions wired to the stack - toolBar->addAction(undoStack->createUndoAction(this)); - toolBar->addAction(undoStack->createRedoAction(this)); + // undo redo — reuse the same actions wired to the stack, with icons + auto undoAction = undoStack->createUndoAction(this); + undoAction->setIcon(QIcon(":/icons/undo.svg")); + toolBar->addAction(undoAction); + auto redoAction = undoStack->createRedoAction(this); + redoAction->setIcon(QIcon(":/icons/redo.svg")); + toolBar->addAction(redoAction); // spacer toolBar->addWidget(spacer); @@ -507,6 +513,7 @@ void MainWindow::setupToolbar() // Export button with dropdown menu auto exportBtn = new QToolButton(this); exportBtn->setText("Export"); + exportBtn->setIcon(QIcon(":/icons/export.svg")); exportBtn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); auto directExportAction = new QAction("Export", this); diff --git a/src/texturelab/widgets/view2dwidget.cpp b/src/texturelab/widgets/view2dwidget.cpp index 05b92ca2..65733621 100644 --- a/src/texturelab/widgets/view2dwidget.cpp +++ b/src/texturelab/widgets/view2dwidget.cpp @@ -43,10 +43,11 @@ const QColor CoarseGridColor(25, 25, 25); View2DWidget::View2DWidget() : QMainWindow() { - // Create toolbar + // Create toolbar (compact — see #View2DToolbar in app.qss.in) toolbar = new QToolBar(this); + toolbar->setObjectName("View2DToolbar"); toolbar->setMovable(false); - toolbar->setIconSize(QSize(24, 24)); + toolbar->setIconSize(QSize(18, 18)); this->addToolBar(Qt::TopToolBarArea, toolbar); // Add save button From 039faa904acfb819c22f5a99245945228f4bad6a Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Mon, 27 Jul 2026 19:50:31 -0500 Subject: [PATCH 140/164] adjust gutter width and color --- resources/qss/ads.qss.in | 25 +++-- src/texturelab/mainwindow.cpp | 186 +++++++++++++++++++++------------- 2 files changed, 132 insertions(+), 79 deletions(-) diff --git a/resources/qss/ads.qss.in b/resources/qss/ads.qss.in index bc813141..4a40fff6 100644 --- a/resources/qss/ads.qss.in +++ b/resources/qss/ads.qss.in @@ -15,9 +15,12 @@ */ /* ---- containers & areas ---- */ -ads--CDockContainerWidget { background: {{bg.window}}; } -/* Strong frame around each panel (DaVinci Resolve-style hard separation). */ -ads--CDockAreaWidget { background: {{bg.panel}}; border: 1px solid {{border.strong}}; } +/* Container is black so the only thing between panels is a single strong line + (the splitter), not a grey gutter -> no "double line" around panels. */ +ads--CDockContainerWidget { background: {{border.strong}}; } +/* Kill the default 1px splitter padding that otherwise shows a grey gap. */ +ads--CDockContainerWidget > QSplitter { padding: 0; } +ads--CDockAreaWidget { background: {{bg.panel}}; border: none; } ads--CDockWidget { background: {{bg.panel}}; border: none; } /* Title bar = the strip holding the tabs + area buttons. Darker than the panel @@ -25,9 +28,10 @@ ads--CDockWidget { background: {{bg.panel}}; border: none; } ads--CDockAreaTitleBar { background: {{bg.elevated}}; border: none; } /* ---- dock widget tabs ---- */ -/* Inactive tabs sit in the dark header. */ +/* Inactive tabs sit in the dark header but stay a step lighter than it so they + don't blend in (header = bg.elevated, inactive tab = gray.800, active = bg.panel). */ ads--CDockWidgetTab { - background: {{bg.elevated}}; + background: {{gray.800}}; border: none; border-right: 1px solid {{border.subtle}}; padding: 5px 14px; @@ -44,9 +48,14 @@ ads--CDockWidgetTab[activeTab="true"] QLabel { color: {{text.primary}}; } ads--CDockWidgetTab:hover QLabel { color: {{text.secondary}}; } /* ---- splitters / resize handles ---- */ -/* Strong (black) gutters between panels to match the panel frames. */ -ads--CDockSplitter::handle { background: {{border.strong}}; } -ads--CDockSplitter::handle:hover { background: {{accent}}; } +/* Strong (black) gutters between panels. NOTE: the gutter THICKNESS is the + splitter handleWidth, which QSplitter ignores from QSS (both ::handle width + and qproperty-handleWidth) — so it's set in C++ (MainWindow, via setHandleWidth + on dockAreaCreated). This rule only colors the gutter. */ +/* Match ADS's own selector specificity (it uses the CDockContainerWidget + descendant form), else its palette(dark) grey wins over our black. */ +ads--CDockContainerWidget ads--CDockSplitter::handle { background: {{border.strong}}; } +ads--CDockContainerWidget ads--CDockSplitter::handle:hover { background: {{accent}}; } ads--CResizeHandle { background: {{border.strong}}; } /* ---- title-bar & tab buttons ---- */ diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 5633f724..fb1ddbce 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -44,14 +44,14 @@ #include "viewer3d.h" -#include "models.h" #include "libraries/libraryversionmigrator.h" #include "libraries/libversion.h" +#include "models.h" #include "project.h" #include "props.h" -#include "graphics/texturerenderer.h" #include "graph/scene.h" +#include "graphics/texturerenderer.h" MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { @@ -59,7 +59,8 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) setAcceptDrops(true); undoStack = new QUndoStack(this); - connect(undoStack, &QUndoStack::cleanChanged, this, &MainWindow::onCleanChanged); + connect(undoStack, &QUndoStack::cleanChanged, this, + &MainWindow::onCleanChanged); this->setupMenus(); this->setupToolbar(); @@ -81,9 +82,11 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) statusLayout->setSpacing(6); statusLayout->addStretch(); // Version + build hash on the left so it's legible in screenshots - // (matches the build artifact name, e.g. texturelab-win-v0.4.0-beta-). + // (matches the build artifact name, e.g. + // texturelab-win-v0.4.0-beta-). auto* versionLabel = new QLabel(QCoreApplication::applicationVersion()); - versionLabel->setObjectName("StatusVersionLabel"); // styled in resources/qss/app.qss.in + versionLabel->setObjectName( + "StatusVersionLabel"); // styled in resources/qss/app.qss.in versionLabel->setToolTip("Application version and build hash"); statusBar()->addWidget(versionLabel); @@ -93,26 +96,42 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) this->dockManager = new ads::CDockManager(this); - // Theme the dock system. ADS installs its own default stylesheet on the dock - // manager (constructor -> loadStylesheet), which overrides the global app - // sheet for ads--* widgets. We keep that default (it carries button icons and - // layout metrics) and append our token-driven color overrides. Rebuilt on - // every theme change so it also picks up --dev-theme hot-reloads. + // Theme the dock system. ADS installs its own default stylesheet on the + // dock manager (constructor -> loadStylesheet), which overrides the global + // app sheet for ads--* widgets. We keep that default (it carries button + // icons and layout metrics) and append our token-driven color overrides. + // Rebuilt on every theme change so it also picks up --dev-theme + // hot-reloads. this->adsDefaultStyleSheet = this->dockManager->styleSheet(); auto applyDockTheme = [this]() { ThemeManager& tm = ThemeManager::instance(); - // Qt prefers an ancestor widget's stylesheet over qApp, so app.qss rules - // (e.g. #AccordionHeader) don't reach widgets inside docks unless we also - // hand them to the dock manager. Order: ADS default -> ADS overrides -> - // app rules (last so our tokens win over ADS's palette()-based defaults). - const QString sheet = this->adsDefaultStyleSheet + "\n" + tm.adsStyleSheet() - + "\n" + tm.appStyleSheet(); - this->dockManager->setStyleSheet(sheet); // theme-exempt: applies composed theme sheet + // Qt prefers an ancestor widget's stylesheet over qApp, so app.qss + // rules (e.g. #AccordionHeader) don't reach widgets inside docks unless + // we also hand them to the dock manager. Order: ADS default -> ADS + // overrides -> app rules (last so our tokens win over ADS's + // palette()-based defaults). + const QString sheet = this->adsDefaultStyleSheet + "\n" + + tm.adsStyleSheet() + "\n" + tm.appStyleSheet(); + this->dockManager->setStyleSheet( + sheet); // theme-exempt: applies composed theme sheet }; applyDockTheme(); - connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, applyDockTheme); + connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, + applyDockTheme); + + // Thicker gutters between panels. ADS/QSplitter ignore QSS handle width, so + // set handleWidth in code — on the initial layout and whenever a new dock + // area (hence a new splitter) is created (drag-docking). + auto applySplitterWidth = [this]() { + for (auto* s : this->dockManager->findChildren()) + s->setHandleWidth(1); + }; + connect( + this->dockManager, &ads::CDockManager::dockAreaCreated, this, + [applySplitterWidth](ads::CDockAreaWidget*) { applySplitterWidth(); }); this->setupDocks(); + applySplitterWidth(); // setup callbacks for the widgets that are created once connect(this->graphWidget, &GraphWidget::nodeSelectionChanged, @@ -177,44 +196,47 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) this->view3DWidget->reRender(); }); - connect(this->propWidget, &PropertiesWidget::textureChannelUpdated, - [this](const TextureChannel& name, const TextureNodePtr& node) { - if (!this->project) - return; + connect( + this->propWidget, &PropertiesWidget::textureChannelUpdated, + [this](const TextureChannel& name, const TextureNodePtr& node) { + if (!this->project) + return; - auto syncViewer = [this]() { - this->passTextureChannelsToViewer3D(); - this->syncChannelLabelsToScene(); - this->view3DWidget->reRender(); - if (this->renderer) - this->renderer->update(); - }; - - if (name == TextureChannel::None) { - // Unassign this node from whichever channel it's in - for (auto ch : this->project->textureChannels.keys()) { - if (this->project->textureChannels[ch] == node->id) { - QString oldNodeId = node->id; - // Apply immediately, then push command (first-redo no-op) - this->project->textureChannels.remove(ch); - syncViewer(); - if (undoStack) - undoStack->push(new TextureChannelAssignCommand( - this->project, ch, oldNodeId, "", syncViewer)); - break; - } + auto syncViewer = [this]() { + this->passTextureChannelsToViewer3D(); + this->syncChannelLabelsToScene(); + this->view3DWidget->reRender(); + if (this->renderer) + this->renderer->update(); + }; + + if (name == TextureChannel::None) { + // Unassign this node from whichever channel it's in + for (auto ch : this->project->textureChannels.keys()) { + if (this->project->textureChannels[ch] == node->id) { + QString oldNodeId = node->id; + // Apply immediately, then push command (first-redo + // no-op) + this->project->textureChannels.remove(ch); + syncViewer(); + if (undoStack) + undoStack->push(new TextureChannelAssignCommand( + this->project, ch, oldNodeId, "", syncViewer)); + break; } } - else { - QString oldNodeId = this->project->textureChannels.value(name, ""); - // Apply immediately, then push command (first-redo no-op) - this->project->textureChannels[name] = node->id; - syncViewer(); - if (undoStack) - undoStack->push(new TextureChannelAssignCommand( - this->project, name, oldNodeId, node->id, syncViewer)); - } - }); + } + else { + QString oldNodeId = + this->project->textureChannels.value(name, ""); + // Apply immediately, then push command (first-redo no-op) + this->project->textureChannels[name] = node->id; + syncViewer(); + if (undoStack) + undoStack->push(new TextureChannelAssignCommand( + this->project, name, oldNodeId, node->id, syncViewer)); + } + }); // set default empty project auto project = TextureProject::createEmpty(); @@ -292,14 +314,22 @@ void MainWindow::passTextureChannelsToViewer3D() static QString channelName(TextureChannel ch) { switch (ch) { - case TextureChannel::Albedo: return "Albedo"; - case TextureChannel::Normal: return "Normal"; - case TextureChannel::Metalness: return "Metalness"; - case TextureChannel::Roughness: return "Roughness"; - case TextureChannel::Height: return "Height"; - case TextureChannel::Alpha: return "Alpha"; - case TextureChannel::AO: return "AO"; - default: return ""; + case TextureChannel::Albedo: + return "Albedo"; + case TextureChannel::Normal: + return "Normal"; + case TextureChannel::Metalness: + return "Metalness"; + case TextureChannel::Roughness: + return "Roughness"; + case TextureChannel::Height: + return "Height"; + case TextureChannel::Alpha: + return "Alpha"; + case TextureChannel::AO: + return "AO"; + default: + return ""; } } @@ -479,10 +509,12 @@ void MainWindow::setupMenus() optionsMenu->addSeparator(); - auto crashReportingAction = optionsMenu->addAction("Send Anonymous Crash Reports"); + auto crashReportingAction = + optionsMenu->addAction("Send Anonymous Crash Reports"); crashReportingAction->setCheckable(true); QSettings settings(QSettings::UserScope, "texturelab", "texturelab"); - crashReportingAction->setChecked(settings.value("crashReporting", true).toBool()); + crashReportingAction->setChecked( + settings.value("crashReporting", true).toBool()); connect(crashReportingAction, &QAction::toggled, [](bool checked) { QSettings s(QSettings::UserScope, "texturelab", "texturelab"); s.setValue("crashReporting", checked); @@ -499,12 +531,21 @@ void MainWindow::setupToolbar() QWidget* spacer = new QWidget(); spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - // undo redo — reuse the same actions wired to the stack, with icons - auto undoAction = undoStack->createUndoAction(this); - undoAction->setIcon(QIcon(":/icons/undo.svg")); + // undo redo — plain "Undo"/"Redo" labels (not the createUndoAction text, + // which appends the command name). Shortcuts stay on the Edit-menu actions + // to avoid an ambiguous-shortcut clash. + auto undoAction = new QAction(QIcon(":/icons/undo.svg"), "Undo", this); + undoAction->setEnabled(undoStack->canUndo()); + connect(undoAction, &QAction::triggered, undoStack, &QUndoStack::undo); + connect(undoStack, &QUndoStack::canUndoChanged, undoAction, + &QAction::setEnabled); toolBar->addAction(undoAction); - auto redoAction = undoStack->createRedoAction(this); - redoAction->setIcon(QIcon(":/icons/redo.svg")); + + auto redoAction = new QAction(QIcon(":/icons/redo.svg"), "Redo", this); + redoAction->setEnabled(undoStack->canRedo()); + connect(redoAction, &QAction::triggered, undoStack, &QUndoStack::redo); + connect(undoStack, &QUndoStack::canRedoChanged, redoAction, + &QAction::setEnabled); toolBar->addAction(redoAction); // spacer @@ -666,7 +707,8 @@ void MainWindow::openProjectFromPath(const QString& filePath) project->name = fileInfo.baseName(); project->filePath = filePath; - Telemetry::breadcrumb("project", "open: " + fileInfo.baseName().toStdString()); + Telemetry::breadcrumb("project", + "open: " + fileInfo.baseName().toStdString()); setProject(project); addToRecentFiles(filePath); } @@ -988,8 +1030,10 @@ void MainWindow::updateRecentFilesMenu() for (const QString& filePath : files) { QFileInfo info(filePath); - auto action = recentFilesMenu->addAction( - info.fileName(), [this, filePath]() { openProjectFromPath(filePath); }); + auto action = + recentFilesMenu->addAction(info.fileName(), [this, filePath]() { + openProjectFromPath(filePath); + }); action->setToolTip(filePath); } From 43e8eeab1933a67a311168f13a341aa25822979d Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Thu, 30 Jul 2026 12:52:37 -0500 Subject: [PATCH 141/164] add qss hotreload by default and remove ads tab accent --- resources/qss/ads.qss.in | 5 +---- resources/qss/app.qss.in | 5 +++-- src/texturelab/CMakeLists.txt | 3 +++ src/texturelab/main.cpp | 32 ++++++++++++++++++++------------ 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/resources/qss/ads.qss.in b/resources/qss/ads.qss.in index 4a40fff6..78ccc3bf 100644 --- a/resources/qss/ads.qss.in +++ b/resources/qss/ads.qss.in @@ -36,12 +36,9 @@ ads--CDockWidgetTab { border-right: 1px solid {{border.subtle}}; padding: 5px 14px; } -/* Active tab merges with the content panel below + gets an accent top indicator. - padding-top compensates for the 2px border so the label doesn't shift. */ +/* Active tab is distinguished by its panel-colored background (no top indicator). */ ads--CDockWidgetTab[activeTab="true"] { background: {{bg.panel}}; - border-top: 2px solid {{accent}}; - padding-top: 3px; } ads--CDockWidgetTab QLabel { color: {{text.disabled}}; } ads--CDockWidgetTab[activeTab="true"] QLabel { color: {{text.primary}}; } diff --git a/resources/qss/app.qss.in b/resources/qss/app.qss.in index 8890c3c8..239cd270 100644 --- a/resources/qss/app.qss.in +++ b/resources/qss/app.qss.in @@ -12,8 +12,9 @@ * scrollbars, tooltips, splitters). Panel-specific styling lands in later * phases (see UI_DESIGN_SYSTEM_PRD.md section 6). * - * Edit + save this file with the app running as `--dev-theme` to see changes - * live (no rebuild). See section on hot-reload in the PRD. + * Edit + save this file to see changes live (no rebuild): hot-reload is ON by + * default in Debug builds (also `--dev-theme` in any build; `--no-dev-theme` to + * disable). See the hot-reload section in UI_DESIGN_SYSTEM_PRD.md. */ /* ============================================================= base ======= */ diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 155aa3e8..2d193125 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -287,6 +287,9 @@ target_compile_definitions(texturelab PRIVATE # Absolute path to the theme source files, so `--dev-theme` can live-reload # colors/QSS from disk without a rebuild. Dev convenience only. TEXTURELAB_SOURCE_RESOURCES="${CMAKE_SOURCE_DIR}/resources" + # Debug builds auto-enable theme hot-reload (no --dev-theme flag needed); + # override at runtime with --no-dev-theme. + $<$:TEXTURELAB_DEV_BUILD> ) # Generate version.h (with git hash) at every build, not just configure time diff --git a/src/texturelab/main.cpp b/src/texturelab/main.cpp index b4f93a0a..33cd06e1 100644 --- a/src/texturelab/main.cpp +++ b/src/texturelab/main.cpp @@ -107,22 +107,30 @@ int main(int argc, char* argv[]) // Consistent dark UI on every platform, regardless of the host system theme. applyDarkTheme(a); - // Dev convenience: `--dev-theme` live-reloads the theme from the on-disk - // source files (resources/…) on save, so colors and QSS can be tuned without - // rebuilding. Off by default; production always uses the compiled-in resources. + // Theme hot-reload: live-reloads the theme from the on-disk source files + // (resources/…) on save, so colors and QSS can be tuned without rebuilding. + // ON BY DEFAULT in Debug builds (TEXTURELAB_DEV_BUILD); off in Release. + // `--dev-theme` forces it on in any build; `--no-dev-theme` forces it off. + bool devTheme = false; +#ifdef TEXTURELAB_DEV_BUILD + devTheme = true; +#endif for (int i = 1; i < argc; ++i) { - if (std::strcmp(argv[i], "--dev-theme") == 0) { + if (std::strcmp(argv[i], "--dev-theme") == 0) + devTheme = true; + else if (std::strcmp(argv[i], "--no-dev-theme") == 0) + devTheme = false; + } + if (devTheme) { #ifdef TEXTURELAB_SOURCE_RESOURCES - const QString res = QStringLiteral(TEXTURELAB_SOURCE_RESOURCES); - ThemeManager::instance().enableHotReload(res + "/themes/dark.json", - res + "/qss/app.qss.in", - res + "/qss/ads.qss.in"); - qInfo("Theme hot-reload enabled, watching %s", qPrintable(res)); + const QString res = QStringLiteral(TEXTURELAB_SOURCE_RESOURCES); + ThemeManager::instance().enableHotReload(res + "/themes/dark.json", + res + "/qss/app.qss.in", + res + "/qss/ads.qss.in"); + qInfo("Theme hot-reload enabled, watching %s", qPrintable(res)); #else - qWarning("--dev-theme: TEXTURELAB_SOURCE_RESOURCES not compiled in"); + qWarning("theme hot-reload requested but TEXTURELAB_SOURCE_RESOURCES not compiled in"); #endif - break; - } } // Now applicationDirPath() is valid — init Sentry From e21238b4a75692e118df2479935f253a576958b0 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Thu, 30 Jul 2026 14:30:50 -0500 Subject: [PATCH 142/164] add segfault errors in viewer3d --- src/viewer3d/viewer3d.h | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/viewer3d/viewer3d.h b/src/viewer3d/viewer3d.h index 8145034d..6e2aeea9 100644 --- a/src/viewer3d/viewer3d.h +++ b/src/viewer3d/viewer3d.h @@ -33,10 +33,14 @@ class Viewer3D : public QOpenGLWidget { QOpenGLBuffer* mesh = nullptr; QOpenGLVertexArrayObject* vao = nullptr; - Renderer* renderer; - Material* material; - Mesh* gltfMesh; - Mesh* skydomeMesh; + // Initialized to nullptr: these are only assigned in initializeGL() (first + // paint), but setProject()/clearTextures() can run earlier (from the + // MainWindow constructor). Without this, the null-guards in clear*Texture() + // dereference uninitialized garbage and crash. See viewer3d.cpp clear*. + Renderer* renderer = nullptr; + Material* material = nullptr; + Mesh* gltfMesh = nullptr; + Mesh* skydomeMesh = nullptr; QString defaultEnvPath; QOpenGLFunctions* gl = nullptr; From 1660791dc3113d6d45ebb502b1777994e3c60d16 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Thu, 30 Jul 2026 15:21:21 -0500 Subject: [PATCH 143/164] more tidying up --- resources/qss/app.qss.in | 30 ++++++++++++++++++++++ src/icons/chevron-down.svg | 3 +++ src/icons/chevron-up.svg | 3 +++ src/icons/copy.svg | 6 ++--- src/icons/crosshair.svg | 9 ++++--- src/icons/grid.svg | 8 +++--- src/icons/save.svg | 7 +++--- src/texturelab/assets.qrc | 2 ++ src/texturelab/mainwindow.cpp | 47 ++++++++++++++++++++++++++++++----- 9 files changed, 97 insertions(+), 18 deletions(-) create mode 100644 src/icons/chevron-down.svg create mode 100644 src/icons/chevron-up.svg diff --git a/resources/qss/app.qss.in b/resources/qss/app.qss.in index 239cd270..cdc39320 100644 --- a/resources/qss/app.qss.in +++ b/resources/qss/app.qss.in @@ -79,6 +79,16 @@ QToolBar::separator { background: {{border.subtle}}; margin: 4px 4px; } +/* Main window toolbar: strong (black) top border matching the ADS dock gutters. + Extra right padding so the Export button isn't flush against the window edge. */ +#MainToolbar { + border-top: 1px solid {{border.strong}}; + padding-right: 10px; +} +/* Hover/pressed on the main toolbar buttons (undo/redo/export): a lighter fill, + no colored outline. */ +#MainToolbar QToolButton:hover { background: {{ctrl.hover}}; } +#MainToolbar QToolButton:pressed { background: {{ctrl.pressed}}; } QToolButton { background: transparent; border: 1px solid transparent; @@ -88,6 +98,9 @@ QToolButton { QToolButton:hover { background: {{ctrl.hover}}; } QToolButton:pressed { background: {{ctrl.pressed}}; } QToolButton:checked { background: {{selection}}; border-color: {{accent.press}}; } +/* Disabled: the global QWidget color rule forces text.primary even when + disabled (QSS color doesn't auto-dim), so dim it back explicitly. */ +QToolButton:disabled { color: {{text.disabled}}; } QToolButton::menu-indicator { image: none; } /* ====================================================== status bar ======== */ @@ -154,6 +167,17 @@ QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover, QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover { background: {{ctrl.hover}}; } +/* Styling the up/down buttons drops the native arrows, so supply them. */ +QSpinBox::up-arrow, QDoubleSpinBox::up-arrow { + image: url(:/icons/chevron-up.svg); + width: 9px; + height: 9px; +} +QSpinBox::down-arrow, QDoubleSpinBox::down-arrow { + image: url(:/icons/chevron-down.svg); + width: 9px; + height: 9px; +} /* ==================================================== combo boxes ========= */ @@ -172,6 +196,12 @@ QComboBox::drop-down { width: 18px; border: none; } +/* Styling ::drop-down drops the native arrow, so supply our own chevron. */ +QComboBox::down-arrow { + image: url(:/icons/chevron-down.svg); + width: 12px; + height: 12px; +} QComboBox QAbstractItemView { background: {{bg.elevated}}; border: 1px solid {{border.subtle}}; diff --git a/src/icons/chevron-down.svg b/src/icons/chevron-down.svg new file mode 100644 index 00000000..6a16f720 --- /dev/null +++ b/src/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/chevron-up.svg b/src/icons/chevron-up.svg new file mode 100644 index 00000000..f56d0b50 --- /dev/null +++ b/src/icons/chevron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/copy.svg b/src/icons/copy.svg index f0f2c154..daed7d43 100644 --- a/src/icons/copy.svg +++ b/src/icons/copy.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/src/icons/crosshair.svg b/src/icons/crosshair.svg index df903263..016b5794 100644 --- a/src/icons/crosshair.svg +++ b/src/icons/crosshair.svg @@ -1,4 +1,7 @@ - - - + + + + + + diff --git a/src/icons/grid.svg b/src/icons/grid.svg index 6a93782f..9c5bb650 100644 --- a/src/icons/grid.svg +++ b/src/icons/grid.svg @@ -1,4 +1,6 @@ - - - + + + + + diff --git a/src/icons/save.svg b/src/icons/save.svg index 1b8f48fc..d0952227 100644 --- a/src/icons/save.svg +++ b/src/icons/save.svg @@ -1,4 +1,5 @@ - - - + + + + diff --git a/src/texturelab/assets.qrc b/src/texturelab/assets.qrc index 02ac8a35..43b09b0e 100644 --- a/src/texturelab/assets.qrc +++ b/src/texturelab/assets.qrc @@ -97,6 +97,8 @@ ../icons/undo.svg ../icons/redo.svg ../icons/export.svg + ../icons/chevron-down.svg + ../icons/chevron-up.svg ../icons/logo.png diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index fb1ddbce..93504b13 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -8,9 +8,12 @@ #include #include #include +#include #include #include #include +#include +#include #include #include #include @@ -33,6 +36,7 @@ #include "exporter.h" #include "telemetry.h" #include "thememanager.h" +#include "tokens.h" #include "undo/undocommands.h" #include "widgets/aboutdialog.h" #include "widgets/exportdialog.h" @@ -110,10 +114,13 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) // we also hand them to the dock manager. Order: ADS default -> ADS // overrides -> app rules (last so our tokens win over ADS's // palette()-based defaults). + // The call below applies the composed theme sheet (ADS default + ADS + // overrides + app rules) to the dock manager — it IS the theming, not an + // inline widget style; the short marker keeps the hygiene gate happy even + // if a formatter wraps the line. const QString sheet = this->adsDefaultStyleSheet + "\n" + tm.adsStyleSheet() + "\n" + tm.appStyleSheet(); - this->dockManager->setStyleSheet( - sheet); // theme-exempt: applies composed theme sheet + this->dockManager->setStyleSheet(sheet); // theme-exempt }; applyDockTheme(); connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, @@ -521,11 +528,39 @@ void MainWindow::setupMenus() }); } +// Recolor a rendered (white) icon pixmap to `color`, keeping its alpha shape. +static QPixmap tintPixmap(const QPixmap& src, const QColor& color) +{ + QPixmap out(src.size()); + out.setDevicePixelRatio(src.devicePixelRatio()); + out.fill(Qt::transparent); + QPainter p(&out); + p.drawPixmap(0, 0, src); + p.setCompositionMode(QPainter::CompositionMode_SourceIn); + p.fillRect(out.rect(), color); + p.end(); + return out; +} + +// Build a toolbar QIcon whose Normal/Disabled variants are tinted to the theme's +// text colors, so the icon always matches the button label's color in each state +// (Qt's auto-generated disabled fade doesn't match text.disabled exactly). +static QIcon themedToolIcon(const QString& svgPath) +{ + const Theme& t = ThemeManager::instance().theme(); + const QPixmap base = QIcon(svgPath).pixmap(QSize(32, 32)); // white source SVG + QIcon icon; + icon.addPixmap(tintPixmap(base, t.color(Tokens::TextPrimary)), QIcon::Normal); + icon.addPixmap(tintPixmap(base, t.color(Tokens::TextDisabled)), QIcon::Disabled); + return icon; +} + void MainWindow::setupToolbar() { // https://www.setnode.com/blog/right-aligning-a-button-in-a-qtoolbar/ toolBar = this->addToolBar("main toolbar"); - toolBar->setIconSize(QSize(18, 18)); + toolBar->setObjectName("MainToolbar"); // styled in app.qss.in + toolBar->setIconSize(QSize(14, 14)); // small, to sit level with the button text toolBar->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); QWidget* spacer = new QWidget(); @@ -534,14 +569,14 @@ void MainWindow::setupToolbar() // undo redo — plain "Undo"/"Redo" labels (not the createUndoAction text, // which appends the command name). Shortcuts stay on the Edit-menu actions // to avoid an ambiguous-shortcut clash. - auto undoAction = new QAction(QIcon(":/icons/undo.svg"), "Undo", this); + auto undoAction = new QAction(themedToolIcon(":/icons/undo.svg"), "Undo", this); undoAction->setEnabled(undoStack->canUndo()); connect(undoAction, &QAction::triggered, undoStack, &QUndoStack::undo); connect(undoStack, &QUndoStack::canUndoChanged, undoAction, &QAction::setEnabled); toolBar->addAction(undoAction); - auto redoAction = new QAction(QIcon(":/icons/redo.svg"), "Redo", this); + auto redoAction = new QAction(themedToolIcon(":/icons/redo.svg"), "Redo", this); redoAction->setEnabled(undoStack->canRedo()); connect(redoAction, &QAction::triggered, undoStack, &QUndoStack::redo); connect(undoStack, &QUndoStack::canRedoChanged, redoAction, @@ -554,7 +589,7 @@ void MainWindow::setupToolbar() // Export button with dropdown menu auto exportBtn = new QToolButton(this); exportBtn->setText("Export"); - exportBtn->setIcon(QIcon(":/icons/export.svg")); + exportBtn->setIcon(themedToolIcon(":/icons/export.svg")); exportBtn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); auto directExportAction = new QAction("Export", this); From 2c9ec0c8f1bd2a3b233b7225df48b7f1ec71ad75 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 2 Aug 2026 07:19:05 -0500 Subject: [PATCH 144/164] fix cases of input texture spill over between disconnected nodes --- src/texturelab/graphics/renderworker.cpp | 30 ++++++++++++--------- src/texturelab/graphics/renderworker.h | 9 +++++-- src/texturelab/graphics/texturerenderer.cpp | 2 +- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/texturelab/graphics/renderworker.cpp b/src/texturelab/graphics/renderworker.cpp index f0113983..4375d091 100644 --- a/src/texturelab/graphics/renderworker.cpp +++ b/src/texturelab/graphics/renderworker.cpp @@ -323,18 +323,20 @@ void RenderWorker::renderSinglePass(const RenderCommand& command) if (command.shaderLinked) { gl->glUseProgram(command.shaderId); - // clear all inputs + // Clear every declared input, not just the connected ones: uniforms + // live on the shader program, so an input that was connected the last + // time this node rendered would otherwise keep its stale texture and + // _connected == true after being disconnected. int texIndex = 0; - for (auto input : command.inputs) { + for (const auto& inputName : command.inputNames) { gl->glActiveTexture(GL_TEXTURE0 + texIndex); gl->glBindTexture(GL_TEXTURE_2D, 0); gl->glUniform1i( gl->glGetUniformLocation(command.shaderId, - input.inputName.toStdString().c_str()), - 0); - std::string connectedName = - input.inputName.toStdString() + "_connected"; + inputName.toStdString().c_str()), + texIndex); + std::string connectedName = inputName.toStdString() + "_connected"; gl->glUniform1i(gl->glGetUniformLocation(command.shaderId, connectedName.c_str()), 0); @@ -343,21 +345,25 @@ void RenderWorker::renderSinglePass(const RenderCommand& command) } // pass inputs - texIndex = 0; for (auto nodeInput : command.inputs) { - gl->glActiveTexture(GL_TEXTURE0 + texIndex); + auto name = nodeInput.inputName; + + // reuse the unit the clear loop above assigned to this input so + // the two stay in sync; unknown names get a fresh unit + int unit = command.inputNames.indexOf(name); + if (unit < 0) + unit = texIndex++; + + gl->glActiveTexture(GL_TEXTURE0 + unit); gl->glBindTexture(GL_TEXTURE_2D, nodeInput.textureId); - auto name = nodeInput.inputName; gl->glUniform1i(gl->glGetUniformLocation( command.shaderId, name.toStdString().c_str()), - texIndex); + unit); std::string connectedName = name.toStdString() + "_connected"; gl->glUniform1i(gl->glGetUniformLocation(command.shaderId, connectedName.c_str()), 1); - - texIndex++; } // pass seed diff --git a/src/texturelab/graphics/renderworker.h b/src/texturelab/graphics/renderworker.h index ec96b636..a3f4b2f5 100644 --- a/src/texturelab/graphics/renderworker.h +++ b/src/texturelab/graphics/renderworker.h @@ -55,8 +55,13 @@ struct RenderCommand { // command even if it's removed from the project while queued/in-flight. TextureNodePtr nodePtr; - // all expected inputs need to be cleared - int totalInputs; + // Every input the node declares, connected or not. Uniform state lives on + // the shader program, so an input left over from a previous render still + // has its sampler and _connected flag set: all declared inputs have + // to be cleared each render, not just the connected ones. + QStringList inputNames; + + // only the inputs that currently have a connection QList inputs; // props diff --git a/src/texturelab/graphics/texturerenderer.cpp b/src/texturelab/graphics/texturerenderer.cpp index 3f0f7268..093241a0 100644 --- a/src/texturelab/graphics/texturerenderer.cpp +++ b/src/texturelab/graphics/texturerenderer.cpp @@ -655,7 +655,7 @@ void TextureRenderer::queueNextNodeToRender() // removed from the project in the meantime). cmd.nodePtr = nextNode; - cmd.totalInputs = nextNode->inputs.size(); + cmd.inputNames = nextNode->inputs; // inputs auto nodeInputs = getNodeInputs(nextNode); From 62a51757d743eb4a7eff484fc29208b0e1a085c6 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 2 Aug 2026 07:19:27 -0500 Subject: [PATCH 145/164] properly mark disconnected nodes as dirty --- src/texturelab/mainwindow.cpp | 31 ++++++++ src/texturelab/mainwindow.h | 6 ++ src/texturelab/models.cpp | 57 ++++++++++++++- src/texturelab/models.h | 8 +++ src/texturelab/undo/addconnectioncommand.cpp | 12 +--- src/texturelab/undo/addnodecommand.cpp | 12 +--- src/texturelab/undo/deleteitemscommand.cpp | 9 +-- src/texturelab/undo/pastecommand.cpp | 9 ++- .../undo/removeconnectioncommand.cpp | 13 ++-- src/texturelab/widgets/graphwidget.cpp | 48 ++++--------- .../widgets/properties/propertieswidget.cpp | 71 +++++++++++++------ .../widgets/properties/propertieswidget.h | 22 ++++++ 12 files changed, 201 insertions(+), 97 deletions(-) diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 93504b13..c92cc254 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -66,6 +66,32 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) connect(undoStack, &QUndoStack::cleanChanged, this, &MainWindow::onCleanChanged); + // Any command (or its undo) can add, remove or re-point the node a + // texture channel maps to — deleting an assigned node being the obvious + // one. Re-sync the viewer and the graph's channel labels from the model + // after every stack move so they can't drift, and kick the render loop in + // case the command marked nodes dirty without rendering them. + connect(undoStack, &QUndoStack::indexChanged, this, [this](int) { + if (!this->project) + return; + + // undo/redo changes prop values behind the properties panel's back + this->propWidget->syncPropBaselines(); + + // this fires on every push too (including merged ones, i.e. every + // step of a slider scrub), so only touch the viewer when the channel + // mapping actually changed + if (this->syncedChannels != this->project->textureChannels) { + this->syncedChannels = this->project->textureChannels; + this->passTextureChannelsToViewer3D(); + this->syncChannelLabelsToScene(); + this->view3DWidget->reRender(); + } + + if (this->renderer) + this->renderer->update(); + }); + this->setupMenus(); this->setupToolbar(); @@ -369,12 +395,14 @@ void MainWindow::setProject(TextureProjectPtr project) if (this->renderer) { this->graphWidget->setTextureRenderer(nullptr); this->view2DWidget->setTextureRenderer(nullptr); + this->propWidget->setTextureRenderer(nullptr); delete this->renderer; this->renderer = nullptr; } this->project = project; + this->syncedChannels = project->textureChannels; this->graphWidget->setTextureProject(project); this->syncChannelLabelsToScene(); this->libraryWidget->setLibrary(project->library); @@ -390,6 +418,9 @@ void MainWindow::setProject(TextureProjectPtr project) renderer->setProject(project); this->graphWidget->setTextureRenderer(renderer); this->view2DWidget->setTextureRenderer(renderer); + // undo/redo of a property change has no propertyUpdated signal to kick the + // render loop, so the commands need the renderer directly + this->propWidget->setTextureRenderer(renderer); connect(renderer, &TextureRenderer::renderProgress, [this](int clean, int total) { diff --git a/src/texturelab/mainwindow.h b/src/texturelab/mainwindow.h index 0b095e92..c58f6c5d 100644 --- a/src/texturelab/mainwindow.h +++ b/src/texturelab/mainwindow.h @@ -2,7 +2,9 @@ #define MAINWINDOW_H #include "DockManager.h" +#include "models.h" #include +#include #include #include #include @@ -100,5 +102,9 @@ class MainWindow : public QMainWindow { QLabel* statusLabel; TextureProjectPtr project; + + // channel→node mapping last pushed to the 3D viewer and the graph's node + // labels, so undo-stack moves that didn't touch it can skip the re-sync + QMap syncedChannels; }; #endif // MAINWINDOW_H diff --git a/src/texturelab/models.cpp b/src/texturelab/models.cpp index c8b9f412..77fbcc13 100644 --- a/src/texturelab/models.cpp +++ b/src/texturelab/models.cpp @@ -4,6 +4,7 @@ #include #include #include +#include TextureNodePtr TextureProject::getNodeById(const QString& id) { @@ -67,7 +68,9 @@ void TextureProject::addConnection(TextureNodePtr leftNode, this->connections[con->id] = con; - // todo: request updates + // the right node now has a different input, so it and everything + // downstream of it must re-render + markNodeAsDirty(rightNode); } ConnectionPtr TextureProject::removeConnection(const QString& leftNode, @@ -84,6 +87,10 @@ ConnectionPtr TextureProject::removeConnection(const QString& leftNode, con->rightNodeInputName == rightNodeInput) { connections.remove(conKey); + // the input is gone: the right node and its whole downstream + // chain render from stale textures until they're re-rendered + markNodeAsDirty(con->rightNode); + return con; } } @@ -93,16 +100,57 @@ ConnectionPtr TextureProject::removeConnection(const QString& leftNode, void TextureProject::removeConnection(ConnectionPtr con) { + if (!con) + return; + this->connections.remove(con->id); + markNodeAsDirty(con->rightNode); } void TextureProject::removeConnection(const QString& id) { + auto con = connections.value(id); this->connections.remove(id); + + if (con) + markNodeAsDirty(con->rightNode); +} + +void TextureProject::removeNode(const QString& id) +{ + // remove every connection touching the node first — removeConnection() + // marks each affected chain dirty so downstream nodes re-render without + // this node's output + for (auto key : connections.keys()) { + auto con = connections.value(key); + if (!con) + continue; + + if ((con->leftNode && con->leftNode->id == id) || + (con->rightNode && con->rightNode->id == id)) + removeConnection(con); + } + + // drop any texture-channel assignment for this node so its stale id can't + // be looked up after deletion + for (auto channel : textureChannels.keys()) { + if (textureChannels.value(channel) == id) + textureChannels.remove(channel); + } + + nodes.remove(id); } void TextureProject::markNodeAsDirty(const TextureNodePtr& node) { + if (!node) + return; + + // visited guards two things: a diamond graph re-walking shared subtrees, + // and a cyclic graph looping here forever + QSet visited; + visited.insert(node->id); + QQueue queue; queue.enqueue(node); @@ -111,8 +159,13 @@ void TextureProject::markNodeAsDirty(const TextureNodePtr& node) nextNode->isDirty = true; auto list = getNodeRightOfNode(nextNode->id); - for (auto item : list) + for (auto item : list) { + if (!item || visited.contains(item->id)) + continue; + + visited.insert(item->id); queue.enqueue(item); + } } } diff --git a/src/texturelab/models.h b/src/texturelab/models.h index 010becbb..a20e74b9 100644 --- a/src/texturelab/models.h +++ b/src/texturelab/models.h @@ -90,7 +90,14 @@ class TextureProject : public QEnableSharedFromThis { void addNode(const TextureNodePtr& node); + // Removes the node along with every connection touching it and any + // texture-channel assignment pointing at it. Downstream nodes are marked + // dirty so they re-render without this node's output. + void removeNode(const QString& id); + // todo: make two port variant + // Adding or removing a connection marks the right node and everything + // downstream of it dirty — callers don't need to do it themselves. void addConnection(TextureNodePtr leftNode, TextureNodePtr rightNode, QString rightNodeInput); @@ -100,6 +107,7 @@ class TextureProject : public QEnableSharedFromThis { void removeConnection(ConnectionPtr con); void removeConnection(const QString& id); + // Marks the node and every node downstream of it as needing a re-render. void markNodeAsDirty(const TextureNodePtr& node); static TextureProjectPtr createEmpty(Library* library = nullptr); diff --git a/src/texturelab/undo/addconnectioncommand.cpp b/src/texturelab/undo/addconnectioncommand.cpp index b886af4b..a9c30c90 100644 --- a/src/texturelab/undo/addconnectioncommand.cpp +++ b/src/texturelab/undo/addconnectioncommand.cpp @@ -26,10 +26,8 @@ void AddConnectionCommand::redo() // Scene already has the connection — just add to project model auto left = _project->getNodeById(_leftNodeId); auto right = _project->getNodeById(_rightNodeId); - if (left && right) { + if (left && right) _project->addConnection(left, right, _rightInput); - right->isDirty = true; - } _firstRedo = false; } else { auto leftG = _scene->getNodeById(_leftNodeId); @@ -39,10 +37,8 @@ void AddConnectionCommand::redo() auto left = _project->getNodeById(_leftNodeId); auto right = _project->getNodeById(_rightNodeId); - if (left && right) { + if (left && right) _project->addConnection(left, right, _rightInput); - right->isDirty = true; - } } if (_renderer) _renderer->update(); @@ -56,9 +52,7 @@ void AddConnectionCommand::undo() if (port && !port->connections.isEmpty()) _scene->removeConnection(port->connections.first()); } - auto right = _project->getNodeById(_rightNodeId); - if (right) - right->isDirty = true; + // removeConnection() marks the right node and its downstream chain dirty _project->removeConnection(_leftNodeId, _rightNodeId, _rightInput); if (_renderer) _renderer->update(); diff --git a/src/texturelab/undo/addnodecommand.cpp b/src/texturelab/undo/addnodecommand.cpp index 2f69f8f2..02e9a367 100644 --- a/src/texturelab/undo/addnodecommand.cpp +++ b/src/texturelab/undo/addnodecommand.cpp @@ -54,16 +54,8 @@ void AddNodeCommand::undo() if (sceneNode) _scene->removeNode(sceneNode); - for (auto key : _project->connections.keys()) { - auto con = _project->connections.value(key); - if (con->leftNode->id == _nodeId || con->rightNode->id == _nodeId) { - if (con->leftNode->id == _nodeId) - con->rightNode->isDirty = true; - _project->connections.remove(key); - } - } - - _project->nodes.remove(_nodeId); + // also drops the node's connections, marking every downstream chain dirty + _project->removeNode(_nodeId); if (_renderer) _renderer->update(); } diff --git a/src/texturelab/undo/deleteitemscommand.cpp b/src/texturelab/undo/deleteitemscommand.cpp index 8ab68c8b..338c1088 100644 --- a/src/texturelab/undo/deleteitemscommand.cpp +++ b/src/texturelab/undo/deleteitemscommand.cpp @@ -111,11 +111,9 @@ DeleteItemsCommand::DeleteItemsCommand(TextureProjectPtr project, void DeleteItemsCommand::redo() { - for (const auto& sc : _connections) { - auto con = _project->removeConnection(sc.leftNodeId, sc.rightNodeId, sc.rightInput); - if (con && con->rightNode) - con->rightNode->isDirty = true; - } + // removeConnection() marks each right node and its downstream chain dirty + for (const auto& sc : _connections) + _project->removeConnection(sc.leftNodeId, sc.rightNodeId, sc.rightInput); for (const auto& sn : _nodes) { auto gnode = _scene->getNodeById(sn.id); @@ -172,7 +170,6 @@ void DeleteItemsCommand::undo() if (!leftNode || !rightNode) continue; _project->addConnection(leftNode, rightNode, sc.rightInput); - rightNode->isDirty = true; auto leftG = _scene->getNodeById(sc.leftNodeId); auto rightG = _scene->getNodeById(sc.rightNodeId); if (leftG && rightG) diff --git a/src/texturelab/undo/pastecommand.cpp b/src/texturelab/undo/pastecommand.cpp index 1a965ae4..3b21c427 100644 --- a/src/texturelab/undo/pastecommand.cpp +++ b/src/texturelab/undo/pastecommand.cpp @@ -53,8 +53,10 @@ void PasteCommand::redo() } for (auto& con : _connections) { + // inserted directly (rather than via addConnection) to keep the + // pasted connection's id stable across undo/redo _project->connections[con->id] = con; - con->rightNode->isDirty = true; + _project->markNodeAsDirty(con->rightNode); auto leftG = _scene->getNodeById(con->leftNode->id); auto rightG = _scene->getNodeById(con->rightNode->id); if (leftG && rightG) @@ -90,14 +92,15 @@ void PasteCommand::redo() void PasteCommand::undo() { + // removeConnection() marks the downstream chain dirty for (auto& con : _connections) - _project->connections.remove(con->id); + _project->removeConnection(con->id); for (auto& node : _nodes) { auto gnode = _scene->getNodeById(node->id); if (gnode) _scene->removeNode(gnode); - _project->nodes.remove(node->id); + _project->removeNode(node->id); } for (auto& comment : _comments) { diff --git a/src/texturelab/undo/removeconnectioncommand.cpp b/src/texturelab/undo/removeconnectioncommand.cpp index bdbeccf6..521fa5d4 100644 --- a/src/texturelab/undo/removeconnectioncommand.cpp +++ b/src/texturelab/undo/removeconnectioncommand.cpp @@ -24,9 +24,8 @@ void RemoveConnectionCommand::redo() { if (_firstRedo) { // Scene already removed it — just remove from project model - auto con = _project->removeConnection(_leftNodeId, _rightNodeId, _rightInput); - if (con && con->rightNode) - con->rightNode->isDirty = true; + // (removeConnection() marks the downstream chain dirty) + _project->removeConnection(_leftNodeId, _rightNodeId, _rightInput); _firstRedo = false; } else { auto rightG = _scene->getNodeById(_rightNodeId); @@ -35,9 +34,7 @@ void RemoveConnectionCommand::redo() if (port && !port->connections.isEmpty()) _scene->removeConnection(port->connections.first()); } - auto con = _project->removeConnection(_leftNodeId, _rightNodeId, _rightInput); - if (con && con->rightNode) - con->rightNode->isDirty = true; + _project->removeConnection(_leftNodeId, _rightNodeId, _rightInput); } if (_renderer) _renderer->update(); @@ -52,10 +49,8 @@ void RemoveConnectionCommand::undo() auto left = _project->getNodeById(_leftNodeId); auto right = _project->getNodeById(_rightNodeId); - if (left && right) { + if (left && right) _project->addConnection(left, right, _rightInput); - right->isDirty = true; - } if (_renderer) _renderer->update(); } diff --git a/src/texturelab/widgets/graphwidget.cpp b/src/texturelab/widgets/graphwidget.cpp index ae992647..ade15dbe 100644 --- a/src/texturelab/widgets/graphwidget.cpp +++ b/src/texturelab/widgets/graphwidget.cpp @@ -82,10 +82,11 @@ GraphWidget::GraphWidget() : QMainWindow(nullptr) project, scene, renderer, leftNodeId, leftOutput, rightNodeId, rightInput)); else { + // addConnection() marks the right node and everything + // downstream of it dirty project->addConnection(project->getNodeById(leftNodeId), project->getNodeById(rightNodeId), rightInput); - project->getNodeById(rightNodeId)->isDirty = true; renderer->update(); } }); @@ -101,10 +102,10 @@ GraphWidget::GraphWidget() : QMainWindow(nullptr) project, scene, renderer, leftNodeId, leftOutput, rightNodeId, rightInput)); else { - auto con2 = project->removeConnection( - leftNodeId, rightNodeId, rightInput); - if (con2 && con2->rightNode) - con2->rightNode->isDirty = true; + // removeConnection() marks the right node and everything + // downstream of it dirty + project->removeConnection(leftNodeId, rightNodeId, + rightInput); renderer->update(); } }); @@ -160,23 +161,9 @@ GraphWidget::GraphWidget() : QMainWindow(nullptr) for (auto& n : nodes) { scene->removeNode(n); - // todo: move this into project class - for (auto key : project->connections.keys()) { - auto con = project->connections.value(key); - if (con->leftNode->id == n->id() || - con->rightNode->id == n->id()) { - if (con->leftNode->id == n->id()) - con->rightNode->isDirty = true; - project->connections.remove(key); - } - } - // Drop any texture-channel assignment for this node so - // its stale id can't be looked up after deletion. - for (auto ch : project->textureChannels.keys()) { - if (project->textureChannels.value(ch) == n->id()) - project->textureChannels.remove(ch); - } - project->nodes.remove(n->id()); + // also drops the node's connections and channel + // assignment, marking downstream nodes dirty + project->removeNode(n->id()); } for (auto& f : frames) { scene->removeFrame(f); @@ -579,18 +566,9 @@ void GraphWidget::executeCut() auto ngNode = scene->getNodeById(id); if (ngNode) scene->removeNode(ngNode); - for (auto key : project->connections.keys()) { - auto con = project->connections.value(key); - if (con->leftNode->id == id || con->rightNode->id == id) - project->connections.remove(key); - } - // Drop any texture-channel assignment for this node so its stale id - // can't be looked up after deletion. - for (auto ch : project->textureChannels.keys()) { - if (project->textureChannels.value(ch) == id) - project->textureChannels.remove(ch); - } - project->nodes.remove(id); + // also drops the node's connections and channel assignment, + // marking downstream nodes dirty + project->removeNode(id); } for (const auto& id : frameIds) { auto f = scene->getFrameById(id); @@ -648,7 +626,7 @@ void GraphWidget::executePaste() } for (auto& con : newConnections) { project->connections[con->id] = con; - con->rightNode->isDirty = true; + project->markNodeAsDirty(con->rightNode); auto l = scene->getNodeById(con->leftNode->id); auto r = scene->getNodeById(con->rightNode->id); if (l && r) diff --git a/src/texturelab/widgets/properties/propertieswidget.cpp b/src/texturelab/widgets/properties/propertieswidget.cpp index 7e49f08c..0e6162ac 100644 --- a/src/texturelab/widgets/properties/propertieswidget.cpp +++ b/src/texturelab/widgets/properties/propertieswidget.cpp @@ -34,33 +34,51 @@ PropertiesWidget::PropertiesWidget() : QWidget() // Helper: push PropertyChangeCommand if undoStack is set; otherwise apply directly. // The value is applied before calling this (first-redo pattern). +// The renderer must be passed through: marking a node dirty doesn't render it, +// something has to call TextureRenderer::update() to kick the render loop, and +// on undo/redo there's no propertyUpdated signal to do it. static void pushPropChange(QUndoStack* stack, TextureNodePtr node, TextureProjectPtr project, - TextureRenderer* /*renderer*/, + TextureRenderer* renderer, const QString& propName, QVariant oldVal, QVariant newVal) { if (stack) stack->push(new PropertyChangeCommand( - node, project, nullptr, propName, oldVal, newVal)); - // renderer=nullptr: PropertiesWidget doesn't hold the renderer; - // markNodeAsDirty already triggers re-render via the renderer's update loop. + node, project, renderer, propName, oldVal, newVal)); +} + +QVariant PropertiesWidget::takePropBaseline(Prop* prop, + const QVariant& newValue) +{ + QVariant oldValue = propBaselines.value(prop, newValue); + propBaselines[prop] = newValue; + + return oldValue; +} + +void PropertiesWidget::syncPropBaselines() +{ + for (auto it = propBaselines.begin(); it != propBaselines.end(); ++it) + it.value() = it.key()->getValue(); } QWidget* PropertiesWidget::createPropWidget(Prop* prop, const TextureNodePtr& node) { + propBaselines[prop] = prop->getValue(); + switch (prop->type) { case PropType::Float: { auto widget = new FloatPropWidget(); widget->setProp((FloatProp*)prop); propWidgets.append(widget); connect(widget, &FloatPropWidget::valueChanged, [=](double value) { - QVariant oldVal = prop->getValue(); + QVariant oldVal = takePropBaseline(prop, value); node->setProp(prop->name, value); project->markNodeAsDirty(node); emit propertyUpdated(prop->name, value); - pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, value); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, value); }); return widget; } @@ -69,11 +87,11 @@ QWidget* PropertiesWidget::createPropWidget(Prop* prop, widget->setProp((BoolProp*)prop); propWidgets.append(widget); connect(widget, &BoolPropWidget::valueChanged, [=](bool value) { - QVariant oldVal = prop->getValue(); + QVariant oldVal = takePropBaseline(prop, value); node->setProp(prop->name, value); project->markNodeAsDirty(node); emit propertyUpdated(prop->name, value); - pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, value); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, value); }); return widget; } @@ -82,11 +100,11 @@ QWidget* PropertiesWidget::createPropWidget(Prop* prop, widget->setProp((IntProp*)prop); propWidgets.append(widget); connect(widget, &IntPropWidget::valueChanged, [=](long value) { - QVariant oldVal = prop->getValue(); + QVariant oldVal = takePropBaseline(prop, (int)value); node->setProp(prop->name, (int)value); project->markNodeAsDirty(node); emit propertyUpdated(prop->name, (int)value); - pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, (int)value); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, (int)value); }); return widget; } @@ -95,11 +113,11 @@ QWidget* PropertiesWidget::createPropWidget(Prop* prop, widget->setProp((EnumProp*)prop); propWidgets.append(widget); connect(widget, &EnumPropWidget::valueChanged, [=](int value) { - QVariant oldVal = prop->getValue(); + QVariant oldVal = takePropBaseline(prop, value); node->setProp(prop->name, value); project->markNodeAsDirty(node); emit propertyUpdated(prop->name, value); - pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, value); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, value); }); return widget; } @@ -108,11 +126,11 @@ QWidget* PropertiesWidget::createPropWidget(Prop* prop, widget->setProp((ColorProp*)prop); propWidgets.append(widget); connect(widget, &ColorPropWidget::valueChanged, [=](const QColor& value) { - QVariant oldVal = prop->getValue(); + QVariant oldVal = takePropBaseline(prop, value); node->setProp(prop->name, value); project->markNodeAsDirty(node); emit propertyUpdated(prop->name, value); - pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, value); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, value); }); return widget; } @@ -121,12 +139,12 @@ QWidget* PropertiesWidget::createPropWidget(Prop* prop, widget->setProp((GradientProp*)prop); propWidgets.append(widget); connect(widget, &GradientPropWidget::valueChanged, [=](const Gradient& value) { - QVariant oldVal = prop->getValue(); QVariant newVal = QVariant::fromValue(value); + QVariant oldVal = takePropBaseline(prop, newVal); node->setProp(prop->name, newVal); project->markNodeAsDirty(node); emit propertyUpdated(prop->name, newVal); - pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, newVal); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, newVal); }); return widget; } @@ -135,11 +153,11 @@ QWidget* PropertiesWidget::createPropWidget(Prop* prop, widget->setProp((ImageProp*)prop); propWidgets.append(widget); connect(widget, &ImagePropWidget::valueChanged, [=](const QImage& value) { - QVariant oldVal = prop->getValue(); + QVariant oldVal = takePropBaseline(prop, value); node->setProp(prop->name, value); project->markNodeAsDirty(node); emit propertyUpdated(prop->name, value); - pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, value); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, value); }); return widget; } @@ -148,11 +166,11 @@ QWidget* PropertiesWidget::createPropWidget(Prop* prop, widget->setProp((StringProp*)prop); propWidgets.append(widget); connect(widget, &StringPropWidget::valueChanged, [=](const QString& value) { - QVariant oldVal = prop->getValue(); + QVariant oldVal = takePropBaseline(prop, value); node->setProp(prop->name, value); project->markNodeAsDirty(node); emit propertyUpdated(prop->name, value); - pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, value); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, value); }); return widget; } @@ -160,12 +178,12 @@ QWidget* PropertiesWidget::createPropWidget(Prop* prop, auto widget = new CurvePropWidget((CurveProp*)prop); propWidgets.append(widget); connect(widget, &CurvePropWidget::valueChanged, [=](const Curve& value) { - QVariant oldVal = prop->getValue(); QVariant newVal = QVariant::fromValue(value); + QVariant oldVal = takePropBaseline(prop, newVal); node->setProp(prop->name, newVal); project->markNodeAsDirty(node); emit propertyUpdated(prop->name, newVal); - pushPropChange(undoStack, node, project, nullptr, prop->name, oldVal, newVal); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, newVal); }); return widget; } @@ -253,7 +271,7 @@ void PropertiesWidget::addBasePropsToLayout() emit this->propertyUpdated("randomSeed", value); if (undoStack) undoStack->push(new RandomSeedChangeCommand( - this->selectedNode, this->project, nullptr, oldSeed, value)); + this->selectedNode, this->project, renderer, oldSeed, value)); }); layout->addWidget(seedWidget); } @@ -385,6 +403,8 @@ void PropertiesWidget::clearSelection() } propWidgets.clear(); + // the props these pointed at may belong to a node that's going away + propBaselines.clear(); } void PropertiesWidget::setProject(const TextureProjectPtr& project) @@ -400,4 +420,9 @@ void PropertiesWidget::setScene(NgScenePtr ngScene) void PropertiesWidget::setUndoStack(QUndoStack* stack) { undoStack = stack; +} + +void PropertiesWidget::setTextureRenderer(TextureRenderer* renderer) +{ + this->renderer = renderer; } \ No newline at end of file diff --git a/src/texturelab/widgets/properties/propertieswidget.h b/src/texturelab/widgets/properties/propertieswidget.h index a6255245..0cdae997 100644 --- a/src/texturelab/widgets/properties/propertieswidget.h +++ b/src/texturelab/widgets/properties/propertieswidget.h @@ -1,7 +1,9 @@ #pragma once +#include #include #include +#include #include #include @@ -20,6 +22,7 @@ typedef QSharedPointer NgScenePtr; class Prop; class EnumProp; class IntProp; +class TextureRenderer; enum class TextureChannel : int; @@ -35,6 +38,9 @@ class PropertiesWidget : public QWidget { TextureProjectPtr project; NgScenePtr scene; QUndoStack* undoStack = nullptr; + // non-owning; needed so undo/redo of a property change can kick the + // render loop (the live edit path goes through propertyUpdated instead) + TextureRenderer* renderer = nullptr; TextureNodePtr selectedNode; FramePtr selectedFrame; CommentPtr selectedComment; @@ -43,6 +49,13 @@ class PropertiesWidget : public QWidget { EnumProp* textureChannelProp; IntProp* randomSeedProp; + // Value each displayed prop held before the edit in progress. The color, + // gradient, image and curve widgets write prop->value themselves before + // emitting valueChanged, so the prop can't be read back for the undo + // baseline — without this their undo steps record oldValue == newValue + // and undoing them changes nothing. + QHash propBaselines; + public: PropertiesWidget(); @@ -54,11 +67,20 @@ class PropertiesWidget : public QWidget { void setProject(const TextureProjectPtr& project); void setScene(NgScenePtr ngScene); void setUndoStack(QUndoStack* stack); + void setTextureRenderer(TextureRenderer* renderer); + + // Re-reads the undo baselines from the props. Call after undo/redo, which + // changes prop values behind the panel's back. + void syncPropBaselines(); private: void addBasePropsToLayout(); QWidget* createPropWidget(Prop* prop, const TextureNodePtr& node); + // Returns the value the prop held before this edit and records newValue + // as the baseline for the next one. + QVariant takePropBaseline(Prop* prop, const QVariant& newValue); + signals: void propertyUpdated(const QString& name, const QVariant& value); void textureChannelUpdated(const TextureChannel& name, From dde2bdabe28f2e89ecde495515b2b9618a2cf0e6 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 2 Aug 2026 12:58:48 -0500 Subject: [PATCH 146/164] address texture state leak --- src/texturelab/graphics/noderenderer.cpp | 43 +++++++++++++++++++++--- src/texturelab/graphics/noderenderer.h | 33 ++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/texturelab/graphics/noderenderer.cpp b/src/texturelab/graphics/noderenderer.cpp index 4700a1b8..87f72130 100644 --- a/src/texturelab/graphics/noderenderer.cpp +++ b/src/texturelab/graphics/noderenderer.cpp @@ -32,12 +32,28 @@ void RenderResourceCache::cleanup() shaderCache.clear(); } +void RenderResourceCache::applyDefaultTextureParams( + QOpenGLFunctions_3_2_Core* gl, GLuint textureId) +{ + gl->glBindTexture(GL_TEXTURE_2D, textureId); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + gl->glBindTexture(GL_TEXTURE_2D, 0); +} + GLuint RenderResourceCache::acquireTexture(int width, int height) { // Reuse an existing free texture of matching size for (auto& tex : texturePool) { if (!tex.inUse && tex.width == width && tex.height == height) { tex.inUse = true; + // Reset here rather than trusting the previous user to have put + // things back: a renderer that returned early, or simply forgot, + // would otherwise hand the next one LINEAR or REPEAT sampling and + // produce a bug that only shows up in certain node orderings. + applyDefaultTextureParams(gl, tex.id); return tex.id; } } @@ -48,11 +64,8 @@ GLuint RenderResourceCache::acquireTexture(int width, int height) gl->glBindTexture(GL_TEXTURE_2D, texId); gl->glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, width, height, 0, GL_RGBA, GL_FLOAT, nullptr); - gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); gl->glBindTexture(GL_TEXTURE_2D, 0); + applyDefaultTextureParams(gl, texId); CachedTexture cached; cached.id = texId; @@ -415,6 +428,28 @@ QString RenderResourceCache::generatePropDeclarations( return code + "\n"; } +// ============================================================================ +// ScopedTextureParams +// ============================================================================ + +ScopedTextureParams::ScopedTextureParams(QOpenGLFunctions_3_2_Core* glFuncs, + GLuint textureId, GLint minFilter, + GLint magFilter, GLint wrap) + : gl(glFuncs), tex(textureId) +{ + gl->glBindTexture(GL_TEXTURE_2D, tex); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, minFilter); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, magFilter); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrap); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrap); + gl->glBindTexture(GL_TEXTURE_2D, 0); +} + +ScopedTextureParams::~ScopedTextureParams() +{ + RenderResourceCache::applyDefaultTextureParams(gl, tex); +} + // ============================================================================ // NodeRenderContext // ============================================================================ diff --git a/src/texturelab/graphics/noderenderer.h b/src/texturelab/graphics/noderenderer.h index 81c85fd7..c62ab258 100644 --- a/src/texturelab/graphics/noderenderer.h +++ b/src/texturelab/graphics/noderenderer.h @@ -36,7 +36,17 @@ class RenderResourceCache { void init(QOpenGLFunctions_3_2_Core* glFuncs, GLuint fboId); void cleanup(); + // The parameters every texture in the pipeline is expected to carry. + // Node shaders sample at exact texel centres, so NEAREST is the correct + // default; CLAMP_TO_EDGE is the default because shaders that want to tile + // fract() their own coordinates. + static void applyDefaultTextureParams(QOpenGLFunctions_3_2_Core* gl, + GLuint textureId); + // --- Intermediate textures --- + // Pooled textures are reset to the default parameters on acquire, so a + // renderer can never inherit filtering or wrapping left behind by whoever + // used the texture last. GLuint acquireTexture(int width, int height); void releaseTexture(GLuint textureId); void releaseAllTextures(); @@ -112,6 +122,29 @@ struct NodeRenderContext { void drawQuad(); }; +// Temporarily re-parameterises a texture — LINEAR for bilinear taps, REPEAT +// for wrapping, a mipmap filter for cone taps — and puts the pipeline defaults +// back when it goes out of scope. +// +// Prefer this over hand-written glTexParameteri pairs. Input textures belong +// to the node upstream and outlive the renderer that borrowed them, so a +// missed restore silently changes how some unrelated node is sampled later; +// with the guard the restore cannot be skipped, including on an early return. +class ScopedTextureParams { +public: + ScopedTextureParams(QOpenGLFunctions_3_2_Core* glFuncs, GLuint textureId, + GLint minFilter, GLint magFilter, + GLint wrap = GL_CLAMP_TO_EDGE); + ~ScopedTextureParams(); + + ScopedTextureParams(const ScopedTextureParams&) = delete; + ScopedTextureParams& operator=(const ScopedTextureParams&) = delete; + +private: + QOpenGLFunctions_3_2_Core* gl; + GLuint tex; +}; + // Abstract base for custom node renderers. // Subclass this to implement multi-pass or custom rendering logic. // The worker delegates to render() instead of the standard single-pass path. From 9d9433551145881917b29af1833a8f54167097de Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 2 Aug 2026 13:01:37 -0500 Subject: [PATCH 147/164] add fast ao and upgrade ao algo --- src/texturelab/CMakeLists.txt | 1 + src/texturelab/libraries/library.cpp | 1 + src/texturelab/libraries/libv3.h | 8 + .../libraries/v3/ambientocclusion.cpp | 68 ++- src/texturelab/libraries/v3/blurhq.cpp | 75 ++- src/texturelab/libraries/v3/fastao.cpp | 435 ++++++++++++++++++ 6 files changed, 529 insertions(+), 59 deletions(-) create mode 100644 src/texturelab/libraries/v3/fastao.cpp diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 2d193125..fbd0e9fc 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -146,6 +146,7 @@ set(LIBRARYV3 ./libraries/v3/blurhq.cpp ./libraries/v3/distancetransform.cpp ./libraries/v3/spread.cpp + ./libraries/v3/fastao.cpp ./libraries/v3/heightblend.cpp ./libraries/v3/makeittile.cpp ./libraries/v3/normalmapv3.cpp diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index b07bdfd9..054c9311 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -282,6 +282,7 @@ Library* createLibraryV3() lib->addNode( "distancetransform", "Distance Transform", ":nodes/bevel.png"); lib->addNode("spread", "Spread", ":nodes/bevel.png"); + lib->addNode("fastao", "Fast AO", ":nodes/bevel.png"); lib->addNode("heightblend", "Height Blend", ":nodes/blend.png"); // lib->addNode("makeittile", "Make It Tile", diff --git a/src/texturelab/libraries/libv3.h b/src/texturelab/libraries/libv3.h index e77b269f..71c7e4b3 100644 --- a/src/texturelab/libraries/libv3.h +++ b/src/texturelab/libraries/libv3.h @@ -151,6 +151,14 @@ class AmbientOcclusionNode : public TextureNode { virtual void init() override; }; +// High-quality multi-pass AO — see v3/fastao.cpp +class FastAONode : public TextureNode { +public: + void init() override; + std::shared_ptr createRenderer() override; + std::shared_ptr createRenderData() override; +}; + class CurvatureNode : public TextureNode { public: virtual void init() override; diff --git a/src/texturelab/libraries/v3/ambientocclusion.cpp b/src/texturelab/libraries/v3/ambientocclusion.cpp index 7efc94bc..d6e81471 100644 --- a/src/texturelab/libraries/v3/ambientocclusion.cpp +++ b/src/texturelab/libraries/v3/ambientocclusion.cpp @@ -2,46 +2,82 @@ #include "../../props.h" #include "../libv3.h" +// Screen-space-style ambient occlusion over a heightfield. +// +// Vogel, "A better way to construct the sunflower head," Math. Biosciences 1979 +// (golden-angle disc — the sample distribution used below) +// https://blog.demofox.org/2017/05/29/when-random-numbers-are-too-random-low-discrepancy-sequences/ +// +// Occlusion is estimated as the mean *elevation angle* of the neighbourhood, +// not the mean height difference: sin(atan(dh/dist)) is bounded in [0,1] per +// sample and correctly scale-aware, so a bump close by occludes far more than +// the same bump at the edge of the radius. +// +// For the high-quality variant (mip-pyramid cone taps, progressive +// accumulation, two-scale detail, bilateral denoise) see fastao.cpp. void AmbientOcclusionNode::init() { this->title = "Ambient Occlusion"; this->addInput("height"); this->addFloatProp("radius", "Radius", 0.05, 0.001, 1.0, 0.005); - this->addIntProp("samples", "Samples", 64, 4, 64, 4); + this->addIntProp("samples", "Samples", 100, 4, 128, 4); this->addFloatProp("intensity", "Intensity", 1.0, 0.1, 5.0, 0.1); - this->addFloatProp("bias", "Bias", 0.01, 0.0, 0.1, 0.005); - this->addFloatProp("height_scale", "Height Scale", 1.0, 0.01, 1.0, 0.01); + this->addFloatProp("bias", "Bias", 0.02, 0.0, 0.5, 0.005); + this->addFloatProp("height_scale", "Height Scale", 1.0, 0.01, 4.0, 0.01); auto source = R""""( vec4 process(vec2 uv) { float centerH = texture(height, uv).r * prop_height_scale; - float occlusion = 0.0; float sampleCount = float(prop_samples); float radius = prop_radius; + // Radius is expressed in UV, so on a non-square texture the + // sampling disc would be an ellipse in texel space. Scale x to + // keep it circular; a no-op when the texture is square. + vec2 aspect = vec2(_textureSize.y / _textureSize.x, 1.0); + + // A single random per pixel rotates the whole spiral. This + // decorrelates neighbouring pixels without reintroducing the + // clumping that per-sample white noise causes. + float rot = randomFloat(0) * 6.28318530718; + + float occlusion = 0.0; + float weightSum = 0.0; + for (int i = 0; i < prop_samples; i++) { - // randomFloat(index) uses _randomStart (per-pixel) + _seed + index - float angle = randomFloat(i * 2) * 6.28318530718; - vec2 dir = vec2(cos(angle), sin(angle)); - float dist = (randomFloat(i * 2 + 1) * 0.75 + 0.25) * radius; + // Vogel disc: golden-angle spiral. Deterministic and evenly + // stratified, so variance falls off far faster than with the + // white-noise angle/distance pair it replaces. + float t = (float(i) + 0.5) / sampleCount; + float angle = float(i) * 2.39996322973 + rot; + // sqrt(t) makes the taps uniform over the disc *area* rather + // than over the radius, which otherwise over-samples the centre. + float dist = sqrt(t) * radius; + + vec2 dir = vec2(cos(angle), sin(angle)) * aspect; - vec2 sampleUV = uv + dir * dist; - float sampleH = texture(height, sampleUV).r * prop_height_scale; + // fract() wraps the tap: node textures are CLAMP_TO_EDGE, so + // without this the AO breaks the seam on a tiling texture. + float sampleH = + texture(height, fract(uv + dir * dist)).r * prop_height_scale; - // Height difference — higher neighbors block light float dh = sampleH - centerH; - // Scale by inverse distance so closer samples matter more - float distFactor = 1.0 - (dist / radius); - float contribution = max(dh - prop_bias, 0.0) * distFactor; + // sin(atan(dh / dist)) — the elevation angle of this neighbour. + float sinElev = dh * inversesqrt(dh * dh + dist * dist); + + // Smooth window over the disc edge, so a feature crossing the + // radius boundary fades in instead of popping. + float w = 1.0 - t; - occlusion += contribution; + occlusion += clamp(sinElev - prop_bias, 0.0, 1.0) * w; + weightSum += w; } - occlusion = occlusion / sampleCount; + occlusion = occlusion / max(weightSum, 0.0001); occlusion = 1.0 - clamp(occlusion * prop_intensity, 0.0, 1.0); return vec4(vec3(occlusion), 1.0); diff --git a/src/texturelab/libraries/v3/blurhq.cpp b/src/texturelab/libraries/v3/blurhq.cpp index 69edba24..42b9c668 100644 --- a/src/texturelab/libraries/v3/blurhq.cpp +++ b/src/texturelab/libraries/v3/blurhq.cpp @@ -52,51 +52,40 @@ class BlurHQRenderer : public NodeTextureRenderer { RenderResourceCache::standardVertexSource(), verticalFrag()); - // Intermediate texture for the horizontal pass result. - // GL_LINEAR is required for the bilinear tap trick in the shaders — - // sampling at fractional offsets must interpolate rather than snap. GLuint intermediate = cache->acquireTexture(w, h); - gl->glBindTexture(GL_TEXTURE_2D, intermediate); - gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - GLuint inputTex = ctx.inputs[0].textureId; - gl->glBindTexture(GL_TEXTURE_2D, inputTex); - gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - gl->glBindTexture(GL_TEXTURE_2D, 0); - - // --- Pass 1: horizontal blur --- - cache->bindFboToTexture(intermediate); - ctx.useShader(hShader); - ctx.bindTexture(hShader, "u_image", inputTex, 0); - gl->glUniform1f( - gl->glGetUniformLocation(hShader, "u_radius"), data.radius); - gl->glUniform2f( - gl->glGetUniformLocation(hShader, "_textureSize"), - float(w), float(h)); - ctx.drawQuad(); - - // --- Pass 2: vertical blur --- - cache->bindFboToTexture(ctx.outputTextureId); - ctx.useShader(vShader); - ctx.bindTexture(vShader, "u_image", intermediate, 0); - gl->glUniform1f( - gl->glGetUniformLocation(vShader, "u_radius"), data.radius); - gl->glUniform2f( - gl->glGetUniformLocation(vShader, "_textureSize"), - float(w), float(h)); - ctx.drawQuad(); - - // Restore GL_NEAREST on both textures — pooled textures are expected - // to be GL_NEAREST; the input texture is owned by another node. - gl->glBindTexture(GL_TEXTURE_2D, inputTex); - gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - gl->glBindTexture(GL_TEXTURE_2D, intermediate); - gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - gl->glBindTexture(GL_TEXTURE_2D, 0); + + { + // GL_LINEAR is required for the bilinear tap trick in the shaders — + // sampling at fractional offsets must interpolate rather than snap. + // The guards put GL_NEAREST back on the way out: the input texture + // belongs to the node upstream and outlives this render. + ScopedTextureParams intermediateParams(gl, intermediate, GL_LINEAR, + GL_LINEAR); + ScopedTextureParams inputParams(gl, inputTex, GL_LINEAR, GL_LINEAR); + + // --- Pass 1: horizontal blur --- + cache->bindFboToTexture(intermediate); + ctx.useShader(hShader); + ctx.bindTexture(hShader, "u_image", inputTex, 0); + gl->glUniform1f( + gl->glGetUniformLocation(hShader, "u_radius"), data.radius); + gl->glUniform2f( + gl->glGetUniformLocation(hShader, "_textureSize"), + float(w), float(h)); + ctx.drawQuad(); + + // --- Pass 2: vertical blur --- + cache->bindFboToTexture(ctx.outputTextureId); + ctx.useShader(vShader); + ctx.bindTexture(vShader, "u_image", intermediate, 0); + gl->glUniform1f( + gl->glGetUniformLocation(vShader, "u_radius"), data.radius); + gl->glUniform2f( + gl->glGetUniformLocation(vShader, "_textureSize"), + float(w), float(h)); + ctx.drawQuad(); + } cache->releaseTexture(intermediate); } diff --git a/src/texturelab/libraries/v3/fastao.cpp b/src/texturelab/libraries/v3/fastao.cpp new file mode 100644 index 00000000..1b66495f --- /dev/null +++ b/src/texturelab/libraries/v3/fastao.cpp @@ -0,0 +1,435 @@ +#include "../../graphics/noderenderer.h" +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +#include +#include +#include + +// Vogel, "A better way to construct the sunflower head," Math. Biosciences 1979 +// (golden-angle disc — the sample distribution used here) +// Jimenez et al., "Next Generation Post Processing in Call of Duty: Advanced +// Warfare," SIGGRAPH 2014 (interleaved gradient noise + matched 4x4 filter) +// Crassin et al., "Interactive Indirect Illumination Using Voxel Cone Tracing," +// PG 2011 (mip level chosen from cone footprint) +// +// Fast AO — the high-quality counterpart to the single-pass Ambient +// Occlusion node. Four ideas the single-pass version cannot express: +// +// 1. Cone taps. A height pyramid is built once, and each tap reads the mip +// level matching its footprint. A far tap then represents the average +// of the region it stands for instead of one arbitrary texel, which is +// where most of the remaining noise and all of the large-radius aliasing +// came from. +// 2. Progressive accumulation. This is an offline texture tool, not a +// 16 ms frame budget, so quality is bought with passes rather than with +// a longer inner loop: N additive passes, each laying down a differently +// rotated spiral, converge to a noise-free result while Draft stays +// interactive. +// 3. Two scales. A broad occlusion term and a tight cavity term are +// accumulated into separate channels and combined at resolve, because a +// single radius always compromises between contact darkening and broad +// ambient shading. +// 4. Bilateral resolve. The per-pixel spiral rotation comes from +// interleaved gradient noise, whose 4x4 tile the resolve pass's 4x4 box +// cancels almost exactly — worth roughly 16x the sample count for the +// cost of one pass. The box is height-guided so it never blurs +// occlusion across a step in the surface. + +// ============================================================================ +// FastAORenderData +// ============================================================================ +struct FastAORenderData : public NodeRenderData { + float radius = 0.08f; + int samples = 48; + int quality = 1; // enum index -> pass count + float intensity = 1.0f; + float bias = 0.02f; + float heightScale = 1.0f; + float detail = 0.35f; + float detailScale = 0.15f; + float denoise = 0.5f; + float contrast = 1.0f; + bool cone = true; +}; + +// ============================================================================ +// FastAORenderer +// ============================================================================ +class FastAORenderer : public NodeTextureRenderer { +public: + void render(NodeRenderContext& ctx, const NodeRenderData& baseData) override + { + auto& data = static_cast(baseData); + auto gl = ctx.gl; + auto cache = ctx.cache; + int w = ctx.textureWidth; + int h = ctx.textureHeight; + + // No input — unoccluded is white, not black. + if (ctx.inputs.isEmpty() || ctx.inputs[0].textureId == 0) { + cache->bindFboToTexture(ctx.outputTextureId); + gl->glViewport(0, 0, w, h); + gl->glClearColor(1, 1, 1, 1); + gl->glClear(GL_COLOR_BUFFER_BIT); + return; + } + + GLuint prepShader = cache->getOrCompileShader( + "fastao_prep", RenderResourceCache::standardVertexSource(), + prepFrag()); + GLuint aoShader = cache->getOrCompileShader( + "fastao_ao", RenderResourceCache::standardVertexSource(), + aoFrag()); + GLuint resolveShader = cache->getOrCompileShader( + "fastao_resolve", RenderResourceCache::standardVertexSource(), + resolveFrag()); + + GLuint heightTex = cache->acquireTexture(w, h); + GLuint accumTex = cache->acquireTexture(w, h); + + // --- Pass 1: linearise the height into its own texture ------------- + // Applying height_scale once here keeps it out of the inner loop, and + // gives us a texture we own and may re-parameterise freely. + cache->bindFboToTexture(heightTex); + ctx.useShader(prepShader); + ctx.bindTexture(prepShader, "u_input", ctx.inputs[0].textureId, 0); + gl->glUniform1f(gl->glGetUniformLocation(prepShader, "u_heightScale"), + data.heightScale); + ctx.drawQuad(); + + // --- Pass 2: accumulate N rotated AO passes ------------------------ + const int passes = passCountFor(data.quality); + + // Point the FBO at the accumulation target *before* generating the + // pyramid: generating mips for a texture still attached to the bound + // framebuffer is a feedback loop the spec leaves undefined. + cache->bindFboToTexture(accumTex); + gl->glViewport(0, 0, w, h); + gl->glClearColor(0, 0, 0, 0); + gl->glClear(GL_COLOR_BUFFER_BIT); + + { + // Trilinear + REPEAT for the duration of the AO passes: the cone + // taps read down the pyramid, and taps have to wrap because the AO + // of a tiling texture has to tile too. The guard restores the + // pool's defaults on the way out. The mip storage stays allocated + // on the texture, but with GL_NEAREST it is never sampled again. + ScopedTextureParams heightParams(gl, heightTex, + GL_LINEAR_MIPMAP_LINEAR, GL_LINEAR, + GL_REPEAT); + // The guard leaves nothing bound, so bind explicitly here — + // glGenerateMipmap acts on whatever is bound to the target. + gl->glBindTexture(GL_TEXTURE_2D, heightTex); + gl->glGenerateMipmap(GL_TEXTURE_2D); + gl->glBindTexture(GL_TEXTURE_2D, 0); + + // Additive blending is what makes accumulation a single texture + // instead of a ping-pong pair; the resolve divides by the pass + // count. + gl->glEnable(GL_BLEND); + gl->glBlendFunc(GL_ONE, GL_ONE); + + for (int p = 0; p < passes; p++) { + ctx.useShader(aoShader); + ctx.bindTexture(aoShader, "u_height", heightTex, 0); + gl->glUniform1f(gl->glGetUniformLocation(aoShader, "u_radius"), + data.radius); + gl->glUniform1i(gl->glGetUniformLocation(aoShader, "u_samples"), + data.samples); + gl->glUniform1f(gl->glGetUniformLocation(aoShader, "u_bias"), + data.bias); + gl->glUniform1f( + gl->glGetUniformLocation(aoShader, "u_detailScale"), + data.detailScale); + gl->glUniform1i(gl->glGetUniformLocation(aoShader, "u_detail"), + data.detail > 0.0f ? 1 : 0); + gl->glUniform1i(gl->glGetUniformLocation(aoShader, "u_cone"), + data.cone ? 1 : 0); + gl->glUniform1i(gl->glGetUniformLocation(aoShader, "u_pass"), p); + ctx.drawQuad(); + } + + gl->glDisable(GL_BLEND); + + // --- Pass 3: bilateral resolve --------------------------------- + cache->bindFboToTexture(ctx.outputTextureId); + ctx.useShader(resolveShader); + ctx.bindTexture(resolveShader, "u_accum", accumTex, 0); + ctx.bindTexture(resolveShader, "u_height", heightTex, 1); + gl->glUniform1f( + gl->glGetUniformLocation(resolveShader, "u_invPasses"), + 1.0f / float(passes)); + gl->glUniform1f( + gl->glGetUniformLocation(resolveShader, "u_intensity"), + data.intensity); + gl->glUniform1f(gl->glGetUniformLocation(resolveShader, "u_detail"), + data.detail); + gl->glUniform1f(gl->glGetUniformLocation(resolveShader, "u_denoise"), + data.denoise); + gl->glUniform1f( + gl->glGetUniformLocation(resolveShader, "u_contrast"), + data.contrast); + ctx.drawQuad(); + } + + cache->releaseTexture(heightTex); + cache->releaseTexture(accumTex); + } + +private: + static int passCountFor(int qualityIndex) + { + switch (qualityIndex) { + case 0: + return 1; // Draft + case 1: + return 2; // Medium + case 2: + return 4; // High + case 3: + return 8; // Ultra + default: + return 2; + } + } + + // Copy the height channel out, pre-scaled, so the AO pass reads a texture + // we control the filtering and wrap mode of. + static QString prepFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_input; + uniform float u_heightScale; + + void main() + { + float h = texture(u_input, v_texCoord).r * u_heightScale; + fragColor = vec4(h, h, h, 1.0); + } + )""""; + } + + // One accumulation pass: a rotated Vogel disc, sampled as cones, writing + // the broad term to R and the cavity term to G. + static QString aoFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_height; + uniform vec2 _textureSize; + uniform float u_radius; + uniform int u_samples; + uniform float u_bias; + uniform float u_detailScale; + uniform int u_detail; + uniform int u_cone; + uniform int u_pass; + + #define TAU 6.28318530718 + #define GOLDEN_ANGLE 2.39996322973 + + // Interleaved gradient noise: over any 4x4 pixel tile this yields + // 16 evenly spread values, which is exactly what the resolve + // pass's 4x4 box is built to average back out. + float ign(vec2 p) + { + return fract(52.9829189 * + fract(dot(p, vec2(0.06711056, 0.00583715)))); + } + + void main() + { + float sampleCount = float(u_samples); + + // Radius is in UV; keep the disc circular in texel space. + vec2 aspect = vec2(_textureSize.y / _textureSize.x, 1.0); + + // Golden-ratio offset per pass, so N passes lay down N + // maximally separated rotations of the same spiral rather + // than N arbitrary ones. + float rot = ign(gl_FragCoord.xy + + vec2(float(u_pass) * 5.588238)) * TAU; + + float centerH = textureLod(u_height, v_texCoord, 0.0).r; + + // N taps over a disc of radius R cannot resolve detail finer + // than their spacing, so prefilter each tap to roughly its + // own footprint instead of point-sampling one texel out of + // the region it is meant to stand for. + float coneScale = (u_cone == 1) ? 2.0 / sqrt(sampleCount) : 0.0; + float texelScale = max(_textureSize.x, _textureSize.y); + + float occBroad = 0.0; + float occFine = 0.0; + float weightSum = 0.0; + + for (int i = 0; i < u_samples; i++) + { + float t = (float(i) + 0.5) / sampleCount; + float angle = float(i) * GOLDEN_ANGLE + rot; + vec2 dir = vec2(cos(angle), sin(angle)) * aspect; + + // Smooth window over the disc edge, so a feature crossing + // the radius boundary fades in instead of popping. + float w = 1.0 - t; + + // --- broad term --- + // sqrt(t) distributes the taps uniformly over the disc + // area rather than over the radius. + float dist = sqrt(t) * u_radius; + float lod = log2(max(1.0, dist * texelScale * coneScale)); + float dh = textureLod(u_height, v_texCoord + dir * dist, + lod).r - centerH; + // sin(atan(dh / dist)) — the elevation angle of this + // neighbour. Bounded in [0,1] and scale-aware, unlike a + // raw height difference. + occBroad += clamp(dh * inversesqrt(dh * dh + dist * dist) + - u_bias, 0.0, 1.0) * w; + + // --- cavity term: the same spiral, tightened --- + if (u_detail == 1) { + float distF = dist * u_detailScale; + float lodF = log2(max(1.0, distF * texelScale * coneScale)); + float dhF = textureLod(u_height, + v_texCoord + dir * distF, + lodF).r - centerH; + occFine += clamp(dhF * inversesqrt(dhF * dhF + distF * distF) + - u_bias, 0.0, 1.0) * w; + } + + weightSum += w; + } + + float inv = 1.0 / max(weightSum, 0.0001); + fragColor = vec4(occBroad * inv, occFine * inv, 0.0, 0.0); + } + )""""; + } + + // Average the accumulation, denoise it, combine the two scales, invert. + static QString resolveFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_accum; + uniform sampler2D u_height; + uniform vec2 _textureSize; + uniform float u_invPasses; + uniform float u_intensity; + uniform float u_detail; + uniform float u_denoise; + uniform float u_contrast; + + void main() + { + vec2 texel = 1.0 / _textureSize; + float centerH = textureLod(u_height, v_texCoord, 0.0).r; + + vec2 raw = texture(u_accum, v_texCoord).rg * u_invPasses; + + // 4x4 box aligned to the interleaved-gradient-noise tile, so + // the 16 complementary rotations laid down by the AO pass + // cancel. Height-guided: occlusion is never blurred across a + // step in the surface, which is what a plain blur would eat. + vec2 sum = vec2(0.0); + float wsum = 0.0; + for (int y = -1; y <= 2; y++) { + for (int x = -1; x <= 2; x++) { + // fract() wraps the tap — pooled textures are + // CLAMP_TO_EDGE and the result has to tile. + vec2 uv = fract(v_texCoord + + vec2(float(x), float(y)) * texel); + float hs = textureLod(u_height, uv, 0.0).r; + float w = exp(-abs(hs - centerH) * 32.0); + sum += texture(u_accum, uv).rg * u_invPasses * w; + wsum += w; + } + } + + vec2 ao = mix(raw, sum / max(wsum, 0.0001), u_denoise); + + float broad = 1.0 - clamp(ao.r * u_intensity, 0.0, 1.0); + float fine = 1.0 - clamp(ao.g * u_intensity, 0.0, 1.0); + + // The cavity term reads as a separate layer of shading — it + // multiplies the broad occlusion rather than adding to it. + float result = broad * mix(1.0, fine, u_detail); + result = pow(max(result, 0.0), u_contrast); + + fragColor = vec4(vec3(result), 1.0); + } + )""""; + } +}; + +// ============================================================================ +// FastAONode +// ============================================================================ +void FastAONode::init() +{ + this->title = "Fast AO"; + + this->addInput("height"); + + this->addFloatProp("radius", "Radius", 0.01, 0.001, 1.0, 0.005); + this->addIntProp("samples", "Samples", 48, 8, 256, 8); + auto qualityEnum = + this->addEnumProp("quality", "Quality", + QList{"Draft", "Medium", "High", "Ultra"}); + qualityEnum->index = 1; // Medium + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 5.0, 0.1); + this->addFloatProp("bias", "Bias", 0.02, 0.0, 0.5, 0.005); + this->addFloatProp("height_scale", "Height Scale", 1.0, 0.01, 8.0, 0.01); + this->addFloatProp("detail", "Detail", 0.35, 0.0, 1.0, 0.01); + this->addFloatProp("detail_scale", "Detail Scale", 0.15, 0.02, 0.5, 0.01); + this->addFloatProp("denoise", "Denoise", 0.5, 0.0, 1.0, 0.01); + this->addFloatProp("contrast", "Contrast", 0.5, 0.25, 4.0, 0.05); + this->addBoolProp("cone", "Cone Filtering", true); +} + +std::shared_ptr FastAONode::createRenderer() +{ + return std::make_shared(); +} + +std::shared_ptr FastAONode::createRenderData() +{ + auto data = std::make_shared(); + + if (auto* p = dynamic_cast(getProp("radius"))) + data->radius = static_cast(p->value); + if (auto* p = dynamic_cast(getProp("samples"))) + data->samples = p->value; + if (auto* p = dynamic_cast(getProp("quality"))) + data->quality = p->index; + if (auto* p = dynamic_cast(getProp("intensity"))) + data->intensity = static_cast(p->value); + if (auto* p = dynamic_cast(getProp("bias"))) + data->bias = static_cast(p->value); + if (auto* p = dynamic_cast(getProp("height_scale"))) + data->heightScale = static_cast(p->value); + if (auto* p = dynamic_cast(getProp("detail"))) + data->detail = static_cast(p->value); + if (auto* p = dynamic_cast(getProp("detail_scale"))) + data->detailScale = static_cast(p->value); + if (auto* p = dynamic_cast(getProp("denoise"))) + data->denoise = static_cast(p->value); + if (auto* p = dynamic_cast(getProp("contrast"))) + data->contrast = static_cast(p->value); + if (auto* p = dynamic_cast(getProp("cone"))) + data->cone = p->value; + + return data; +} From b96c7c6e40152ad10f1c72bf99f5460760a463cd Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Thu, 6 Aug 2026 14:29:23 -0500 Subject: [PATCH 148/164] add launcher --- CMakeLists.txt | 15 + resources/qss/app.qss.in | 62 ++ resources/themes/dark.json | 199 +++--- src/catalog/CMakeLists.txt | 40 ++ src/catalog/catalogindex.cpp | 662 ++++++++++++++++++ src/catalog/catalogindex.h | 131 ++++ src/catalog/database.cpp | 279 ++++++++ src/catalog/database.h | 109 +++ src/catalog/texturerecord.h | 88 +++ src/catalog/thumbnailcache.cpp | 328 +++++++++ src/catalog/thumbnailcache.h | 109 +++ src/texturelab/CMakeLists.txt | 13 + src/texturelab/catalogservice.cpp | 395 +++++++++++ src/texturelab/catalogservice.h | 96 +++ src/texturelab/launcher/launcherformat.cpp | 172 +++++ src/texturelab/launcher/launcherformat.h | 46 ++ src/texturelab/launcher/launcherwindow.cpp | 654 +++++++++++++++++ src/texturelab/launcher/launcherwindow.h | 92 +++ .../launcher/texturecarddelegate.cpp | 260 +++++++ src/texturelab/launcher/texturecarddelegate.h | 44 ++ src/texturelab/launcher/texturelistmodel.cpp | 255 +++++++ src/texturelab/launcher/texturelistmodel.h | 116 +++ .../launcher/texturerowdelegate.cpp | 173 +++++ src/texturelab/launcher/texturerowdelegate.h | 32 + src/texturelab/main.cpp | 33 +- src/texturelab/mainwindow.cpp | 147 +++- src/texturelab/mainwindow.h | 19 + src/theme/tokens.h | 11 + tests/CMakeLists.txt | 40 ++ tests/tst_catalogindex.cpp | 501 +++++++++++++ tests/tst_database.cpp | 293 ++++++++ tests/tst_texturelistmodel.cpp | 290 ++++++++ tests/tst_thumbnailcache.cpp | 339 +++++++++ 33 files changed, 5952 insertions(+), 91 deletions(-) create mode 100644 src/catalog/CMakeLists.txt create mode 100644 src/catalog/catalogindex.cpp create mode 100644 src/catalog/catalogindex.h create mode 100644 src/catalog/database.cpp create mode 100644 src/catalog/database.h create mode 100644 src/catalog/texturerecord.h create mode 100644 src/catalog/thumbnailcache.cpp create mode 100644 src/catalog/thumbnailcache.h create mode 100644 src/texturelab/catalogservice.cpp create mode 100644 src/texturelab/catalogservice.h create mode 100644 src/texturelab/launcher/launcherformat.cpp create mode 100644 src/texturelab/launcher/launcherformat.h create mode 100644 src/texturelab/launcher/launcherwindow.cpp create mode 100644 src/texturelab/launcher/launcherwindow.h create mode 100644 src/texturelab/launcher/texturecarddelegate.cpp create mode 100644 src/texturelab/launcher/texturecarddelegate.h create mode 100644 src/texturelab/launcher/texturelistmodel.cpp create mode 100644 src/texturelab/launcher/texturelistmodel.h create mode 100644 src/texturelab/launcher/texturerowdelegate.cpp create mode 100644 src/texturelab/launcher/texturerowdelegate.h create mode 100644 tests/CMakeLists.txt create mode 100644 tests/tst_catalogindex.cpp create mode 100644 tests/tst_database.cpp create mode 100644 tests/tst_texturelistmodel.cpp create mode 100644 tests/tst_thumbnailcache.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 31d86698..c6c7c054 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,6 +42,9 @@ add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src/ads) # Theme / design-token system (single source of truth for colors, QSS, palette) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src/theme) +# Launcher data layer (index.db + thumbs.db). Headless, no widgets. +add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src/catalog) + # Node Graph add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src/nodegraph) @@ -54,3 +57,15 @@ add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src/colorpicker) # Main App add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src/texturelab) +# Unit tests (QtTest). Off in release builds; enable with -DTEXTURELAB_BUILD_TESTS=ON. +option(TEXTURELAB_BUILD_TESTS "Build the unit test suite" ON) +if(TEXTURELAB_BUILD_TESTS) + find_package(Qt6 COMPONENTS Test QUIET) + if(Qt6Test_FOUND) + enable_testing() + add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/tests) + else() + message(STATUS "Qt6 Test module not found — skipping unit tests") + endif() +endif() + diff --git a/resources/qss/app.qss.in b/resources/qss/app.qss.in index cdc39320..e7c7c9a3 100644 --- a/resources/qss/app.qss.in +++ b/resources/qss/app.qss.in @@ -423,3 +423,65 @@ QPushButton[size="small"] { background: {{ctrl.hover}}; color: {{text.primary}}; } + +/* ==================================================== launcher =========== */ + +/* Project-manager window. Two thin bars with the card grid between them; the + cards themselves are painted by TextureCardDelegate, not styled here. */ + +#launcherTopBar, +#launcherActionBar { + background: {{bg.panel}}; +} +#launcherTopBar { border-bottom: 1px solid {{border.subtle}}; } +#launcherActionBar { border-top: 1px solid {{border.subtle}}; } + +#launcherGrid { + background: {{bg.elevated}}; + border: none; +} +/* The delegate owns the whole card, so the view must not draw its own + selection fill underneath it. */ +#launcherGrid::item, +#launcherGrid::item:hover, +#launcherGrid::item:selected { + background: transparent; + border: none; +} + +/* All / Recents / Starred, and the grid/list toggle — segmented controls that + fill when active rather than carrying an underline. Grey rather than accent + on purpose: the material thumbnails are meant to be the only saturated thing + in the window (see LAUNCHER_PRD.md §3.5). */ +#launcherFilterTab { + background: transparent; + border: none; + border-radius: {{radius.sm}}px; + color: {{text.secondary}}; + padding: 4px 10px; + font-size: 13px; +} +#launcherFilterTab:hover { + color: {{text.primary}}; + background: {{ctrl.bg}}; +} +#launcherFilterTab:checked { + color: {{text.primary}}; + background: {{ctrl.hover}}; +} + +#launcherEmptyLabel { + background: transparent; + color: {{text.secondary}}; + font-size: 14px; +} + +/* Home button in the status bar — reopens the launcher */ +#StatusHomeButton { + background: transparent; + border: none; + color: {{text.secondary}}; + padding: 0px 6px; + font-size: 14px; +} +#StatusHomeButton:hover { color: {{text.primary}}; } diff --git a/resources/themes/dark.json b/resources/themes/dark.json index 4a17013e..65d0642d 100644 --- a/resources/themes/dark.json +++ b/resources/themes/dark.json @@ -1,6 +1,8 @@ { - "meta": { "name": "TextureLab Dark", "base": "dark" }, - + "meta": { + "name": "TextureLab Dark", + "base": "dark" + }, "color": { "gray.900": "#191919", "gray.850": "#232323", @@ -13,98 +15,125 @@ "gray.400": "#7F7F7F", "gray.300": "#787878", "gray.200": "#C8C8C8", - "white": "#FFFFFF", - "black": "#000000", - "accent": "#2A82DA", + "white": "#FFFFFF", + "black": "#000000", + "accent": "#2A82DA", "accent.hover": "#3D93E8", "accent.press": "#2069B8", - "warn": "#E5A54B", - "danger": "#E5484D", - "ok": "#3DAF6E", - - "bg.window": "@gray.700", - "bg.panel": "@gray.700", - "bg.base": "@gray.850", - "bg.elevated": "@gray.900", - "bg.input": "@gray.750", - "border.subtle": "@gray.900", - "border.strong": "@black", - "border.input": "@gray.600", - "text.primary": "@white", + "warn": "#E5A54B", + "danger": "#E5484D", + "ok": "#3DAF6E", + "bg.window": "@gray.700", + "bg.panel": "@gray.700", + "bg.base": "@gray.850", + "bg.elevated": "@gray.900", + "bg.input": "@gray.750", + "border.subtle": "@gray.900", + "border.strong": "@black", + "border.input": "@gray.600", + "text.primary": "@white", "text.secondary": "@gray.200", - "text.disabled": "@gray.400", - "selection": "@accent", - - "ctrl.bg": "@gray.650", - "ctrl.hover": "@gray.600", - "ctrl.pressed": "@gray.550", - "ctrl.checked": "@accent", - - "node.bg": "#0A0A0A", - "node.border": "@black", - "node.border.hover": "@gray.300", + "text.disabled": "@gray.400", + "selection": "@accent", + "ctrl.bg": "@gray.650", + "ctrl.hover": "@gray.600", + "ctrl.pressed": "@gray.550", + "ctrl.checked": "@accent", + "node.bg": "#0A0A0A", + "node.border": "@black", + "node.border.hover": "@gray.300", "node.border.select": "@gray.200", - "node.title": "@white", - "node.channel": "#C8FFC8", - "socket.fill": "#AAAAAA", - "wire": "#AAAAAA", - "wire.dragging": "#969696", - "wire.selected": "@accent", - "grid.bg": "@gray.700", - "grid.fine": "#3C3C3C", - "grid.coarse": "@gray.900", - "checker.a": "#C0C0C0", - "checker.b": "#808080", - "frame.select": "@warn", - "comment.fill": "@white", - "comment.text": "#F0F0F0", - - "view2d.bg": "#212121", - "view3d.clear": "#1A1A1A", - "view3d.grid": "@gray.600", - - "curve.bg": "@bg.elevated", - "curve.grid": "@gray.850", - "curve.identity": "@gray.800", - "curve.line": "@text.secondary", - "curve.anchor": "@text.disabled", - "curve.anchor.hover": "@text.primary", - "curve.anchor.select": "@accent", - "curve.handle.line": "@gray.600", - "curve.handle.dot": "@text.disabled", - "curve.handle.hover": "@text.secondary", - "curve.handle.corner": "@warn" + "node.title": "@white", + "node.channel": "#C8FFC8", + "socket.fill": "#AAAAAA", + "wire": "#AAAAAA", + "wire.dragging": "#969696", + "wire.selected": "@accent", + "grid.bg": "@gray.700", + "grid.fine": "#3C3C3C", + "grid.coarse": "@gray.900", + "checker.a": "#C0C0C0", + "checker.b": "#808080", + "frame.select": "@warn", + "comment.fill": "@white", + "comment.text": "#F0F0F0", + "view2d.bg": "#212121", + "view3d.clear": "#1A1A1A", + "view3d.grid": "@gray.600", + "curve.bg": "@bg.elevated", + "curve.grid": "@gray.850", + "curve.identity": "@gray.800", + "curve.line": "@text.secondary", + "curve.anchor": "@text.disabled", + "curve.anchor.hover": "@text.primary", + "curve.anchor.select": "@accent", + "curve.handle.line": "@gray.600", + "curve.handle.dot": "@text.disabled", + "curve.handle.hover": "@text.secondary", + "curve.handle.corner": "@warn", + "launcher.card": "@gray.800", + "launcher.card.hover": "@gray.700", + "launcher.card.border": "@gray.900", + "launcher.thumb.bg": "@gray.850", + "launcher.star": "@warn", + "launcher.badge": "@warn", + "launcher.pip.off": "@gray.650", + "launcher.pip.on": "@gray.200", + "launcher.open.dot": "@ok" }, - "palette": { - "window": "@bg.window", - "windowText": "@text.primary", - "base": "@bg.base", - "alternateBase": "@bg.window", - "toolTipBase": "@gray.900", - "toolTipText": "@text.primary", - "text": "@text.primary", - "button": "@bg.window", - "buttonText": "@text.primary", - "brightText": "@danger", - "link": "@accent", - "highlight": "@accent", + "window": "@bg.window", + "windowText": "@text.primary", + "base": "@bg.base", + "alternateBase": "@bg.window", + "toolTipBase": "@gray.900", + "toolTipText": "@text.primary", + "text": "@text.primary", + "button": "@bg.window", + "buttonText": "@text.primary", + "brightText": "@danger", + "link": "@accent", + "highlight": "@accent", "highlightedText": "@black", - - "disabled.windowText": "@text.disabled", - "disabled.text": "@text.disabled", - "disabled.buttonText": "@text.disabled", + "disabled.windowText": "@text.disabled", + "disabled.text": "@text.disabled", + "disabled.buttonText": "@text.disabled", "disabled.highlightedText": "@text.disabled", - "disabled.highlight": "@gray.600" + "disabled.highlight": "@gray.600" + }, + "radius": { + "sm": 3, + "md": 5, + "lg": 8, + "pill": 999 + }, + "space": { + "xs": 2, + "sm": 4, + "md": 8, + "lg": 12, + "xl": 16 + }, + "motion": { + "fast": 120, + "base": 180, + "slow": 260 }, - - "radius": { "sm": 3, "md": 5, "lg": 8, "pill": 999 }, - "space": { "xs": 2, "sm": 4, "md": 8, "lg": 12, "xl": 16 }, - "motion": { "fast": 120, "base": 180, "slow": 260 }, - "font": { - "ui": { "family": "Segoe UI, Inter, sans-serif", "size": 12, "weight": 400 }, - "mono": { "family": "Consolas, JetBrains Mono, monospace", "size": 12, "weight": 400 }, - "title": { "family": "Segoe UI, Inter, sans-serif", "size": 13, "weight": 600 } + "ui": { + "family": "Segoe UI, Inter, sans-serif", + "size": 12, + "weight": 400 + }, + "mono": { + "family": "Consolas, JetBrains Mono, monospace", + "size": 12, + "weight": 400 + }, + "title": { + "family": "Segoe UI, Inter, sans-serif", + "size": 13, + "weight": 600 + } } } diff --git a/src/catalog/CMakeLists.txt b/src/catalog/CMakeLists.txt new file mode 100644 index 00000000..d9a9a9e2 --- /dev/null +++ b/src/catalog/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.10) + +# The launcher's data layer: index.db (what textures exist) and thumbs.db +# (their cached previews). See LAUNCHER_PRD.md. +# +# Deliberately free of any dependency on the app's node graph or on Qt Widgets, +# so it builds and tests headless. The mapping between TextureChannel and this +# library's ChannelBit lives at the app boundary, not here. + +project(catalog LANGUAGES CXX) + +set(CMAKE_AUTOMOC ON) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Sets QT_VERSION_MAJOR, matching how the other subdirectories resolve Qt. +find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core) +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Sql) + +add_library(catalog STATIC + database.h + database.cpp + catalogindex.h + catalogindex.cpp + thumbnailcache.h + thumbnailcache.cpp + texturerecord.h +) + +target_include_directories(catalog PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + +# Qt6::Sql pulls in the bundled QSQLITE driver plugin. The deploy tooling +# (linuxdeploy --plugin qt, windeployqt, macdeployqt) copies sqldrivers/ +# automatically for any target that links it. +target_link_libraries(catalog PUBLIC + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Sql +) + +set_target_properties(catalog PROPERTIES POSITION_INDEPENDENT_CODE ON) diff --git a/src/catalog/catalogindex.cpp b/src/catalog/catalogindex.cpp new file mode 100644 index 00000000..dab6f8b0 --- /dev/null +++ b/src/catalog/catalogindex.cpp @@ -0,0 +1,662 @@ +#include "catalogindex.h" + +#include +#include +#include +#include + +namespace catalog { + +namespace { + +// Column order shared by every SELECT, so readRow() can stay a single function. +const char* const kColumns = + "id, path, name, file_size, file_mtime, width, height, " + "node_count, lib_version, channels, created_at, last_opened, last_saved, " + "starred, missing_since"; + +// Escapes the LIKE metacharacters so a search for "50%" doesn't match +// everything. Paired with ESCAPE '\' in the query. +QString escapeLike(const QString& term) +{ + QString out = term; + out.replace(QLatin1Char('\\'), QLatin1String("\\\\")); + out.replace(QLatin1Char('%'), QLatin1String("\\%")); + out.replace(QLatin1Char('_'), QLatin1String("\\_")); + return out; +} + +QVariant nullIfZero(qint64 value) +{ + return value == 0 ? QVariant() : QVariant(value); +} + +} // namespace + +CatalogIndex::CatalogIndex() = default; + +CatalogIndex::~CatalogIndex() +{ + close(); +} + +bool CatalogIndex::open(const QString& path) +{ + close(); + + // index.db holds no blobs and is read far more than written, so the + // defaults are right; only thumbs.db needs page_size/auto_vacuum tuning. + Database::Options options; + options.walMode = true; + options.foreignKeys = true; + + if (!db.open(path, options)) + return false; + + const bool fresh = db.scalar( + QStringLiteral("SELECT count(*) FROM sqlite_master WHERE type='table'")) + == 0; + + if (fresh) { + if (!createSchema()) { + close(); + return false; + } + currentVersion = SchemaVersion; + return true; + } + + currentVersion = readSchemaVersion(); + + if (currentVersion > SchemaVersion) { + // Written by a newer build. Migrating backwards is guesswork and this + // file cannot be regenerated by rescanning, so reopen read-only and let + // the launcher show what it can. + qWarning("catalog: index schema v%d is newer than this build (v%d); opening read-only", + currentVersion, SchemaVersion); + db.close(); + + Database::Options ro = options; + ro.readOnly = true; + if (!db.open(path, ro)) + return false; + + readOnly = true; + return true; + } + + if (currentVersion < SchemaVersion && !migrate(currentVersion)) { + close(); + return false; + } + + return true; +} + +void CatalogIndex::close() +{ + db.close(); + readOnly = false; + currentVersion = 0; +} + +bool CatalogIndex::createSchema() +{ + QStringList statements; + + statements << QStringLiteral(R"( + CREATE TABLE texture ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + file_size INTEGER NOT NULL DEFAULT 0, + file_mtime INTEGER NOT NULL DEFAULT 0, + width INTEGER, + height INTEGER, + node_count INTEGER, + lib_version TEXT, + channels INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + last_opened INTEGER, + last_saved INTEGER, + starred INTEGER NOT NULL DEFAULT 0, + missing_since INTEGER + ) + )"); + + statements << QStringLiteral(R"( + CREATE TABLE tag ( + texture_id INTEGER NOT NULL REFERENCES texture(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (texture_id, tag) + ) WITHOUT ROWID + )"); + + statements << QStringLiteral( + "CREATE INDEX ix_recent ON texture(last_opened DESC) WHERE missing_since IS NULL"); + statements << QStringLiteral("CREATE INDEX ix_name ON texture(name)"); + + statements << QStringLiteral("CREATE TABLE meta (k TEXT PRIMARY KEY, v TEXT)"); + statements << QStringLiteral("INSERT INTO meta (k, v) VALUES ('schema_version', '%1')") + .arg(SchemaVersion); + + return db.execBatch(statements); +} + +int CatalogIndex::readSchemaVersion() +{ + QSqlQuery query = db.prepare(QStringLiteral("SELECT v FROM meta WHERE k = 'schema_version'")); + if (!query.exec() || !query.next()) + return 0; + + return query.value(0).toInt(); +} + +bool CatalogIndex::writeSchemaVersion(int version) +{ + QSqlQuery query = db.prepare(QStringLiteral( + "INSERT INTO meta (k, v) VALUES ('schema_version', ?) " + "ON CONFLICT(k) DO UPDATE SET v = excluded.v")); + query.addBindValue(QString::number(version)); + if (!query.exec()) { + db.setError(query.lastError().text()); + return false; + } + currentVersion = version; + return true; +} + +bool CatalogIndex::migrate(int fromVersion) +{ + // No migrations yet — v1 is the first shipped schema. When one is needed, + // add a step here per version, each inside its own transaction, and bump + // SchemaVersion. Steps must be additive: this file is the only copy of the + // user's history. + Q_UNUSED(fromVersion); + return writeSchemaVersion(SchemaVersion); +} + +// --- write points --------------------------------------------------------- + +bool CatalogIndex::recordOpened(TextureRecord& rec, qint64 whenMs) +{ + return upsert(rec, Stamp::Opened, whenMs); +} + +bool CatalogIndex::recordSaved(TextureRecord& rec, qint64 whenMs) +{ + return upsert(rec, Stamp::Saved, whenMs); +} + +bool CatalogIndex::upsert(TextureRecord& rec, Stamp stamp, qint64 whenMs) +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return false; + } + if (rec.path.isEmpty()) { + db.setError(QStringLiteral("cannot record a texture with no path")); + return false; + } + + if (rec.name.isEmpty()) + rec.name = QFileInfo(rec.path).completeBaseName(); + + if (stamp == Stamp::Opened) + rec.lastOpened = whenMs; + else + rec.lastSaved = whenMs; + + // created_at is preserved on conflict; every other column reflects what we + // just learned from the document. starred and missing_since are handled + // separately: starring is a user action this call knows nothing about, and + // touching a file necessarily means it isn't missing. + QSqlQuery query = db.prepare(QStringLiteral(R"( + INSERT INTO texture (path, name, file_size, file_mtime, + width, height, node_count, lib_version, channels, + created_at, last_opened, last_saved, starred, missing_since) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL) + ON CONFLICT(path) DO UPDATE SET + name = excluded.name, + file_size = excluded.file_size, + file_mtime = excluded.file_mtime, + width = excluded.width, + height = excluded.height, + node_count = excluded.node_count, + lib_version = excluded.lib_version, + channels = excluded.channels, + last_opened = max(coalesce(texture.last_opened, 0), + coalesce(excluded.last_opened, 0)), + last_saved = max(coalesce(texture.last_saved, 0), + coalesce(excluded.last_saved, 0)), + missing_since = NULL + )")); + + query.addBindValue(rec.path); + query.addBindValue(rec.name); + query.addBindValue(rec.fileSize); + query.addBindValue(rec.fileMtime); + query.addBindValue(rec.width); + query.addBindValue(rec.height); + query.addBindValue(rec.nodeCount); + query.addBindValue(rec.libVersion.isEmpty() ? QVariant() : QVariant(rec.libVersion)); + query.addBindValue(rec.channels); + query.addBindValue(rec.createdAt != 0 ? rec.createdAt : whenMs); + query.addBindValue(nullIfZero(rec.lastOpened)); + query.addBindValue(nullIfZero(rec.lastSaved)); + + if (!query.exec()) { + db.setError(query.lastError().text()); + qWarning("catalog: upsert failed for %s: %s", qPrintable(rec.path), + qPrintable(db.lastError())); + return false; + } + + // max() above can leave the in-memory record behind the stored row, and the + // id is unknown on the update path, so read back rather than guess. + const TextureRecord stored = byPath(rec.path); + if (!stored.isValid()) { + db.setError(QStringLiteral("row vanished immediately after upsert")); + return false; + } + rec = stored; + return true; +} + +bool CatalogIndex::setStarred(qint64 id, bool starred) +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return false; + } + + QSqlQuery query = db.prepare(QStringLiteral("UPDATE texture SET starred = ? WHERE id = ?")); + query.addBindValue(starred ? 1 : 0); + query.addBindValue(id); + if (!query.exec()) { + db.setError(query.lastError().text()); + return false; + } + return query.numRowsAffected() > 0; +} + +bool CatalogIndex::remove(qint64 id) +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return false; + } + + QSqlQuery query = db.prepare(QStringLiteral("DELETE FROM texture WHERE id = ?")); + query.addBindValue(id); + if (!query.exec()) { + db.setError(query.lastError().text()); + return false; + } + return query.numRowsAffected() > 0; +} + +int CatalogIndex::removeAllMissing() +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return 0; + } + + QSqlQuery query = db.prepare( + QStringLiteral("DELETE FROM texture WHERE missing_since IS NOT NULL")); + if (!query.exec()) { + db.setError(query.lastError().text()); + return 0; + } + return query.numRowsAffected(); +} + +bool CatalogIndex::clearRecents() +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return false; + } + + return db.exec(QStringLiteral("UPDATE texture SET last_opened = NULL")); +} + +// --- reconciliation ------------------------------------------------------- + +QVector CatalogIndex::allRecords() const +{ + QVector records; + + QSqlQuery query = const_cast(db).prepare( + QStringLiteral("SELECT %1 FROM texture").arg(QLatin1String(kColumns))); + if (!query.exec()) + return records; + + while (query.next()) + records.append(readRow(query)); + + return records; +} + +bool CatalogIndex::markMissing(qint64 id, qint64 whenMs) +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return false; + } + + // Only stamp the first time, so the dimmed card can say how long it's been + // gone rather than resetting on every launcher open. + QSqlQuery query = db.prepare(QStringLiteral( + "UPDATE texture SET missing_since = ? WHERE id = ? AND missing_since IS NULL")); + query.addBindValue(whenMs); + query.addBindValue(id); + if (!query.exec()) { + db.setError(query.lastError().text()); + return false; + } + return true; +} + +bool CatalogIndex::markPresent(qint64 id, qint64 fileSize, qint64 fileMtime) +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return false; + } + + QSqlQuery query = db.prepare(QStringLiteral( + "UPDATE texture SET missing_since = NULL, file_size = ?, file_mtime = ? WHERE id = ?")); + query.addBindValue(fileSize); + query.addBindValue(fileMtime); + query.addBindValue(id); + if (!query.exec()) { + db.setError(query.lastError().text()); + return false; + } + return true; +} + +qint64 CatalogIndex::relocate(qint64 id, const QString& newPath, qint64 fileSize, + qint64 fileMtime) +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return -1; + } + if (newPath.isEmpty()) + return -1; + + const TextureRecord moving = byId(id); + if (!moving.isValid()) + return -1; + + Transaction tx(db); + if (!tx.isActive()) + return -1; + + const TextureRecord existing = byPath(newPath); + + // Already indexed under the new path — usually because the user opened it + // there before getting round to fixing the stale card. Fold the old row's + // user data into it rather than failing on the UNIQUE constraint. + if (existing.isValid() && existing.id != id) { + QSqlQuery merge = db.prepare(QStringLiteral( + "UPDATE texture SET " + " starred = max(starred, ?), " + " created_at = min(created_at, ?), " + " last_opened = max(coalesce(last_opened, 0), ?), " + " last_saved = max(coalesce(last_saved, 0), ?), " + " missing_since = NULL " + "WHERE id = ?")); + merge.addBindValue(moving.starred ? 1 : 0); + merge.addBindValue(moving.createdAt != 0 ? moving.createdAt : existing.createdAt); + merge.addBindValue(moving.lastOpened); + merge.addBindValue(moving.lastSaved); + merge.addBindValue(existing.id); + if (!merge.exec()) { + db.setError(merge.lastError().text()); + return -1; + } + + QSqlQuery moveTags = db.prepare( + QStringLiteral("INSERT OR IGNORE INTO tag (texture_id, tag) " + "SELECT ?, tag FROM tag WHERE texture_id = ?")); + moveTags.addBindValue(existing.id); + moveTags.addBindValue(id); + if (!moveTags.exec()) { + db.setError(moveTags.lastError().text()); + return -1; + } + + QSqlQuery drop = db.prepare(QStringLiteral("DELETE FROM texture WHERE id = ?")); + drop.addBindValue(id); + if (!drop.exec()) { + db.setError(drop.lastError().text()); + return -1; + } + + return tx.commit() ? existing.id : -1; + } + + QSqlQuery query = db.prepare(QStringLiteral( + "UPDATE texture SET path = ?, name = ?, file_size = ?, file_mtime = ?, " + "missing_since = NULL WHERE id = ?")); + query.addBindValue(newPath); + query.addBindValue(QFileInfo(newPath).completeBaseName()); + query.addBindValue(fileSize); + query.addBindValue(fileMtime); + query.addBindValue(id); + + if (!query.exec()) { + db.setError(query.lastError().text()); + return -1; + } + + return tx.commit() ? id : -1; +} + +// --- reads ---------------------------------------------------------------- + +QString CatalogIndex::whereClause(const Query& query) +{ + QStringList clauses; + + switch (query.filter) { + case Filter::All: + break; + case Filter::Recents: + // Matches the ix_recent partial index. Recents is "jump back in", so a + // file that isn't there isn't useful; All and Starred still show it. + clauses << QStringLiteral("last_opened IS NOT NULL") + << QStringLiteral("missing_since IS NULL"); + break; + case Filter::Starred: + clauses << QStringLiteral("starred = 1"); + break; + } + + if (!query.search.isEmpty()) { + clauses << QStringLiteral( + R"((name LIKE :search ESCAPE '\' OR EXISTS ( + SELECT 1 FROM tag WHERE tag.texture_id = texture.id + AND tag.tag LIKE :search ESCAPE '\')))"); + } + + if (clauses.isEmpty()) + return QString(); + + return QStringLiteral(" WHERE ") + clauses.join(QStringLiteral(" AND ")); +} + +QString CatalogIndex::orderByClause(const Query& query) +{ + const QString direction = query.ascending ? QStringLiteral("ASC") : QStringLiteral("DESC"); + + switch (query.sort) { + case SortKey::Name: + return QStringLiteral(" ORDER BY name COLLATE NOCASE %1, id %1").arg(direction); + case SortKey::Size: + return QStringLiteral(" ORDER BY file_size %1, id %1").arg(direction); + case SortKey::Opened: + // Spelled out rather than using NULLS LAST so the ordering doesn't + // depend on which SQLite version Qt happens to bundle. Never-opened + // rows sort last either way. + return QStringLiteral(" ORDER BY (last_opened IS NULL) ASC, last_opened %1, id %1") + .arg(direction); + case SortKey::Modified: + break; + } + return QStringLiteral(" ORDER BY file_mtime %1, id %1").arg(direction); +} + +QVector CatalogIndex::list(const Query& query) const +{ + QVector records; + + QString sql = QStringLiteral("SELECT %1 FROM texture").arg(QLatin1String(kColumns)); + sql += whereClause(query); + sql += orderByClause(query); + + if (query.limit >= 0) + sql += QStringLiteral(" LIMIT :limit OFFSET :offset"); + + QSqlQuery q = const_cast(db).prepare(sql); + if (!query.search.isEmpty()) + q.bindValue(QStringLiteral(":search"), + QStringLiteral("%%%1%%").arg(escapeLike(query.search))); + if (query.limit >= 0) { + q.bindValue(QStringLiteral(":limit"), query.limit); + q.bindValue(QStringLiteral(":offset"), query.offset); + } + + if (!q.exec()) { + const_cast(db).setError(q.lastError().text()); + qWarning("catalog: list failed: %s", qPrintable(q.lastError().text())); + return records; + } + + while (q.next()) + records.append(readRow(q)); + + return records; +} + +int CatalogIndex::count(const Query& query) const +{ + QString sql = QStringLiteral("SELECT count(*) FROM texture"); + sql += whereClause(query); + + QSqlQuery q = const_cast(db).prepare(sql); + if (!query.search.isEmpty()) + q.bindValue(QStringLiteral(":search"), + QStringLiteral("%%%1%%").arg(escapeLike(query.search))); + + if (!q.exec() || !q.next()) + return 0; + + return q.value(0).toInt(); +} + +TextureRecord CatalogIndex::readRow(const QSqlQuery& query) +{ + TextureRecord rec; + rec.id = query.value(0).toLongLong(); + rec.path = query.value(1).toString(); + rec.name = query.value(2).toString(); + rec.fileSize = query.value(3).toLongLong(); + rec.fileMtime = query.value(4).toLongLong(); + rec.width = query.value(5).toInt(); + rec.height = query.value(6).toInt(); + rec.nodeCount = query.value(7).toInt(); + rec.libVersion = query.value(8).toString(); + rec.channels = query.value(9).toInt(); + rec.createdAt = query.value(10).toLongLong(); + rec.lastOpened = query.value(11).isNull() ? 0 : query.value(11).toLongLong(); + rec.lastSaved = query.value(12).isNull() ? 0 : query.value(12).toLongLong(); + rec.starred = query.value(13).toBool(); + rec.missingSince = query.value(14).isNull() ? 0 : query.value(14).toLongLong(); + return rec; +} + +TextureRecord CatalogIndex::byId(qint64 id) const +{ + QSqlQuery query = const_cast(db).prepare( + QStringLiteral("SELECT %1 FROM texture WHERE id = ?").arg(QLatin1String(kColumns))); + query.addBindValue(id); + + if (!query.exec() || !query.next()) + return TextureRecord(); + + return readRow(query); +} + +TextureRecord CatalogIndex::byPath(const QString& path) const +{ + QSqlQuery query = const_cast(db).prepare( + QStringLiteral("SELECT %1 FROM texture WHERE path = ?").arg(QLatin1String(kColumns))); + query.addBindValue(path); + + if (!query.exec() || !query.next()) + return TextureRecord(); + + return readRow(query); +} + +// --- tags ----------------------------------------------------------------- + +QStringList CatalogIndex::tags(qint64 id) const +{ + QStringList result; + + QSqlQuery query = const_cast(db).prepare( + QStringLiteral("SELECT tag FROM tag WHERE texture_id = ? ORDER BY tag")); + query.addBindValue(id); + + if (!query.exec()) + return result; + + while (query.next()) + result << query.value(0).toString(); + + return result; +} + +bool CatalogIndex::addTag(qint64 id, const QString& tag) +{ + if (readOnly || tag.isEmpty()) { + db.setError(QStringLiteral("index is read-only or tag is empty")); + return false; + } + + QSqlQuery query = db.prepare( + QStringLiteral("INSERT OR IGNORE INTO tag (texture_id, tag) VALUES (?, ?)")); + query.addBindValue(id); + query.addBindValue(tag); + if (!query.exec()) { + db.setError(query.lastError().text()); + return false; + } + return true; +} + +bool CatalogIndex::removeTag(qint64 id, const QString& tag) +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return false; + } + + QSqlQuery query = db.prepare( + QStringLiteral("DELETE FROM tag WHERE texture_id = ? AND tag = ?")); + query.addBindValue(id); + query.addBindValue(tag); + if (!query.exec()) { + db.setError(query.lastError().text()); + return false; + } + return query.numRowsAffected() > 0; +} + +} // namespace catalog diff --git a/src/catalog/catalogindex.h b/src/catalog/catalogindex.h new file mode 100644 index 00000000..8c343e0d --- /dev/null +++ b/src/catalog/catalogindex.h @@ -0,0 +1,131 @@ +#pragma once + +#include "database.h" +#include "texturerecord.h" + +#include +#include + +namespace catalog { + +// Repository over index.db — the durable record of every texture the app has +// created, opened, or saved. +// +// There is no filesystem scanner. Rows appear here only because the user did +// something, which means this file is the *only* record of what the launcher +// knows; losing it loses history even though every .texture file is still on +// disk. Two consequences are baked into this class: nothing is ever deleted +// implicitly (missing files are flagged, not removed), and a database written +// by a newer build is opened read-only rather than migrated speculatively. +class CatalogIndex { +public: + // Bump when the schema changes, and add a step to migrate(). + static constexpr int SchemaVersion = 1; + + CatalogIndex(); + ~CatalogIndex(); + + CatalogIndex(const CatalogIndex&) = delete; + CatalogIndex& operator=(const CatalogIndex&) = delete; + + // Creates the schema if the file is new, migrates it if it's older, and + // falls back to read-only if it's newer than this build understands. + bool open(const QString& path); + void close(); + + bool isOpen() const { return db.isOpen(); } + + // True when the file was written by a newer build. Every mutating call + // fails in this state; the launcher should still show what it can. + bool isReadOnly() const { return readOnly; } + + int schemaVersion() const { return currentVersion; } + QString lastError() const { return db.lastError(); } + Database& database() { return db; } + + // --- write points (see LAUNCHER_PRD.md §6.1) ------------------------- + + // Upserts by path and stamps last_opened. On success `rec.id` is filled in. + // + // Paths are identity here. A texture moved on disk becomes a new row at its + // new path, and the old one stays behind, flagged missing until the user + // removes it — no attempt is made to recognize the two as the same file. + // Detecting that needs a content hash or an mtime heuristic, and neither + // earns its keep for how often textures actually move. + bool recordOpened(TextureRecord& rec, qint64 whenMs); + + // Upserts by path and stamps last_saved. + bool recordSaved(TextureRecord& rec, qint64 whenMs); + + bool setStarred(qint64 id, bool starred); + + // Forgets a texture. Never touches the file on disk. + bool remove(qint64 id); + int removeAllMissing(); + + // Clears last_opened everywhere, emptying the Recents view. Keeps the rows, + // their stars, and their tags — "clear recents" is about history, not about + // discarding what the user has collected. + bool clearRecents(); + + // --- reconciliation (see LAUNCHER_PRD.md §6.2) ----------------------- + + // Every row, cheapest form, for the stat() pass on launcher open. + QVector allRecords() const; + + bool markMissing(qint64 id, qint64 whenMs); + + // Records that a file is present with this size/mtime, clearing any missing + // flag. Metadata is refreshed on next open, not here — this pass must not + // parse files. + // + // A caller that sees size or mtime differ from the stored row knows the + // file was edited outside the app, and should drop that texture's cached + // thumbnail before calling this. That comparison is the only change + // detection in the system. + bool markPresent(qint64 id, qint64 fileSize, qint64 fileMtime); + + // Re-points a row at a new path — the user found a file that had gone + // missing. This is the deliberate counterpart to not detecting moves + // automatically (§6.2): the launcher can't guess, but the user can tell it. + // + // If another row already occupies `newPath`, the two are merged: the + // survivor keeps that path and inherits stars, tags, and the earlier + // created_at, and the relocated row is deleted. Returns the surviving row + // id, or -1 on failure. + qint64 relocate(qint64 id, const QString& newPath, qint64 fileSize, qint64 fileMtime); + + // --- reads ------------------------------------------------------------ + + QVector list(const Query& query) const; + int count(const Query& query) const; + + TextureRecord byId(qint64 id) const; + TextureRecord byPath(const QString& path) const; + + // --- tags ------------------------------------------------------------- + + QStringList tags(qint64 id) const; + bool addTag(qint64 id, const QString& tag); + bool removeTag(qint64 id, const QString& tag); + +private: + enum class Stamp { Opened, Saved }; + + bool createSchema(); + bool migrate(int fromVersion); + int readSchemaVersion(); + bool writeSchemaVersion(int version); + + bool upsert(TextureRecord& rec, Stamp stamp, qint64 whenMs); + + static TextureRecord readRow(const class QSqlQuery& query); + static QString orderByClause(const Query& query); + static QString whereClause(const Query& query); + + Database db; + bool readOnly = false; + int currentVersion = 0; +}; + +} // namespace catalog diff --git a/src/catalog/database.cpp b/src/catalog/database.cpp new file mode 100644 index 00000000..0172c846 --- /dev/null +++ b/src/catalog/database.cpp @@ -0,0 +1,279 @@ +#include "database.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace catalog { + +namespace { + +// Qt keys connections by name in a process-wide registry, so every Database +// instance needs its own. The thread id is in the name purely to make a +// cross-thread misuse obvious in a debugger. +QString makeConnectionName() +{ + static QAtomicInteger counter(0); + return QStringLiteral("texturelab_catalog_%1_%2") + .arg(reinterpret_cast(QThread::currentThreadId()), 0, 16) + .arg(counter.fetchAndAddRelaxed(1)); +} + +bool isMemoryPath(const QString& path) +{ + return path == QLatin1String(":memory:") || path.startsWith(QLatin1String("file::memory:")); +} + +// Guards addDatabase/removeDatabase only. Qt's connection registry is a +// process-wide map, and the reconciliation pass opens its own connections from +// a worker thread while the GUI thread holds its own. Individual connections +// stay thread-confined; this just keeps two threads from mutating the registry +// at the same moment. +QMutex& registryMutex() +{ + static QMutex mutex; + return mutex; +} + +} // namespace + +Database::Database() = default; + +Database::~Database() +{ + close(); +} + +bool Database::open(const QString& path, const Options& options) +{ + close(); + + connectionName = makeConnectionName(); + dbPath = path; + + // Every QSqlDatabase copy must be destroyed before removeDatabase() runs, + // or Qt warns that the connection is still in use and leaves it registered. + // That includes the failure paths below, hence the scoping. + bool openFailed = false; + { + QMutexLocker lock(®istryMutex()); + QSqlDatabase conn = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connectionName); + conn.setDatabaseName(path); + + QStringList connectOptions; + connectOptions << QStringLiteral("QSQLITE_BUSY_TIMEOUT=%1").arg(options.busyTimeoutMs); + if (options.readOnly) + connectOptions << QStringLiteral("QSQLITE_OPEN_READONLY"); + conn.setConnectOptions(connectOptions.join(QLatin1Char(';'))); + + if (!conn.open()) { + errorText = conn.lastError().text(); + openFailed = true; + } + } + + if (openFailed) { + qWarning("catalog: could not open %s: %s", qPrintable(path), qPrintable(errorText)); + { + QMutexLocker lock(®istryMutex()); + QSqlDatabase::removeDatabase(connectionName); + } + connectionName.clear(); + return false; + } + + opened = true; + + if (!applyPragmas(options)) { + close(); + return false; + } + + return true; +} + +void Database::close() +{ + if (!connectionName.isEmpty()) { + if (transactionActive) + rollbackTransaction(); + { + QSqlDatabase db = QSqlDatabase::database(connectionName, false); + if (db.isValid() && db.isOpen()) + db.close(); + } + // The QSqlDatabase copy above must be out of scope before + // removeDatabase, or Qt warns about a connection still in use. + { + QMutexLocker lock(®istryMutex()); + QSqlDatabase::removeDatabase(connectionName); + } + connectionName.clear(); + } + opened = false; + transactionActive = false; + dbPath.clear(); +} + +bool Database::isOpen() const +{ + return opened && handle().isOpen(); +} + +QSqlDatabase Database::handle() const +{ + return QSqlDatabase::database(connectionName, false); +} + +bool Database::applyPragmas(const Options& options) +{ + const bool memory = isMemoryPath(dbPath); + + // Order matters and is not negotiable. page_size and auto_vacuum can only + // take effect on a database with no pages yet, and journal_mode=WAL writes + // a page — so both must precede it. Getting this backwards fails silently: + // the PRAGMA reports success and the setting simply doesn't apply. + if (!options.readOnly && !memory) { + if (options.pageSize > 0 + && !exec(QStringLiteral("PRAGMA page_size = %1").arg(options.pageSize))) + return false; + + if (options.incrementalAutoVacuum && !exec(QStringLiteral("PRAGMA auto_vacuum = INCREMENTAL"))) + return false; + + if (options.walMode) { + if (!exec(QStringLiteral("PRAGMA journal_mode = WAL"))) + return false; + if (!exec(QStringLiteral("PRAGMA synchronous = NORMAL"))) + return false; + } + } + + if (options.foreignKeys && !exec(QStringLiteral("PRAGMA foreign_keys = ON"))) + return false; + + return true; +} + +bool Database::exec(const QString& sql) +{ + QSqlQuery query(handle()); + if (!query.exec(sql)) { + errorText = query.lastError().text(); + qWarning("catalog: query failed: %s [%s]", qPrintable(errorText), qPrintable(sql)); + return false; + } + return true; +} + +bool Database::execBatch(const QStringList& statements) +{ + Transaction tx(*this); + if (!tx.isActive()) + return false; + + for (const QString& sql : statements) { + if (!exec(sql)) + return false; + } + + return tx.commit(); +} + +qint64 Database::scalar(const QString& sql, qint64 fallback) +{ + QSqlQuery query(handle()); + if (!query.exec(sql) || !query.next()) + return fallback; + + const QVariant value = query.value(0); + return value.isNull() ? fallback : value.toLongLong(); +} + +QSqlQuery Database::prepare(const QString& sql) +{ + QSqlQuery query(handle()); + if (!query.prepare(sql)) { + errorText = query.lastError().text(); + qWarning("catalog: prepare failed: %s [%s]", qPrintable(errorText), qPrintable(sql)); + } + return query; +} + +bool Database::beginTransaction() +{ + if (transactionActive) { + errorText = QStringLiteral("transaction already active"); + return false; + } + // BEGIN IMMEDIATE takes the write lock up front. The default deferred + // transaction takes it at the first write, which under WAL can fail with + // SQLITE_BUSY partway through a batch that already read data — the classic + // "upgrade deadlock" that busy_timeout cannot resolve. + if (!exec(QStringLiteral("BEGIN IMMEDIATE"))) + return false; + + transactionActive = true; + return true; +} + +bool Database::commitTransaction() +{ + if (!transactionActive) { + errorText = QStringLiteral("no transaction to commit"); + return false; + } + const bool ok = exec(QStringLiteral("COMMIT")); + transactionActive = false; + return ok; +} + +bool Database::rollbackTransaction() +{ + if (!transactionActive) + return false; + + const bool ok = exec(QStringLiteral("ROLLBACK")); + transactionActive = false; + return ok; +} + +Transaction::Transaction(Database& database) : db(&database) +{ + if (db->inTransaction()) { + qWarning("catalog: nested transaction requested; inner scope is inert"); + return; + } + active = db->beginTransaction(); +} + +Transaction::~Transaction() +{ + if (active) + rollback(); +} + +bool Transaction::commit() +{ + if (!active) + return false; + + active = false; + return db->commitTransaction(); +} + +void Transaction::rollback() +{ + if (!active) + return; + + active = false; + db->rollbackTransaction(); +} + +} // namespace catalog diff --git a/src/catalog/database.h b/src/catalog/database.h new file mode 100644 index 00000000..d760bcb5 --- /dev/null +++ b/src/catalog/database.h @@ -0,0 +1,109 @@ +#pragma once + +#include +#include + +class QSqlQuery; + +namespace catalog { + +// A single SQLite connection, opened through Qt's bundled QSQLITE driver. +// +// One connection per thread, never shared: QSqlDatabase handles are tied to the +// thread that opened them, and WAL only makes concurrent *connections* safe, not +// concurrent use of one handle. Each Database instance registers its own +// uniquely-named Qt connection, so several can coexist over the same file. +class Database { +public: + struct Options { + // Applied before any other PRAGMA and before any DDL. SQLite can only + // honor these on an empty database file: page_size otherwise needs a + // full VACUUM to take effect, and auto_vacuum cannot be raised from + // NONE at all without one. 0 leaves the driver default in place. + int pageSize = 0; + bool incrementalAutoVacuum = false; + + bool walMode = true; + bool foreignKeys = true; + int busyTimeoutMs = 5000; + bool readOnly = false; + }; + + Database(); + ~Database(); + + Database(const Database&) = delete; + Database& operator=(const Database&) = delete; + + // `path` may be ":memory:" for a private in-memory database, which is what + // the tests use. Options that only apply to a file (page_size, WAL) are + // skipped in that case rather than failing. + bool open(const QString& path, const Options& options); + + // Spelled as an overload rather than a defaulted argument: a default + // argument of `Options()` would need the nested struct's member + // initializers before the enclosing class is complete. + bool open(const QString& path) { return open(path, Options()); } + void close(); + + bool isOpen() const; + QString path() const { return dbPath; } + QSqlDatabase handle() const; + + // Runs a statement that takes no parameters and returns no rows. On failure + // the driver's message is recorded in lastError() and logged. + bool exec(const QString& sql); + + // Runs several statements in one transaction, stopping at the first + // failure. Used for schema creation. + bool execBatch(const QStringList& statements); + + // Single-value query; returns `fallback` on any failure or empty result. + qint64 scalar(const QString& sql, qint64 fallback = 0); + + // Prepared statement bound to this connection. Always check isValid() on + // the returned query — a prepare failure is reported here, not at exec(). + QSqlQuery prepare(const QString& sql); + + bool beginTransaction(); + bool commitTransaction(); + bool rollbackTransaction(); + bool inTransaction() const { return transactionActive; } + + QString lastError() const { return errorText; } + void setError(const QString& text) { errorText = text; } + +private: + bool applyPragmas(const Options& options); + + QString connectionName; + QString dbPath; + QString errorText; + bool opened = false; + bool transactionActive = false; +}; + +// RAII transaction. Rolls back on destruction unless commit() succeeded, so an +// early return or a failed step can never leave a half-written batch behind. +// +// Does not nest: constructing one while another is active is a programming +// error, and the inner instance becomes inert rather than committing the outer +// transaction out from under it. +class Transaction { +public: + explicit Transaction(Database& db); + ~Transaction(); + + Transaction(const Transaction&) = delete; + Transaction& operator=(const Transaction&) = delete; + + bool isActive() const { return active; } + bool commit(); + void rollback(); + +private: + Database* db = nullptr; + bool active = false; +}; + +} // namespace catalog diff --git a/src/catalog/texturerecord.h b/src/catalog/texturerecord.h new file mode 100644 index 00000000..bcf360ad --- /dev/null +++ b/src/catalog/texturerecord.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include + +namespace catalog { + +// Output channel bits, mirroring TextureChannel in src/texturelab/models.h. +// +// Deliberately redeclared instead of including models.h: the catalog library +// must not depend on the app's node graph, so that it stays testable on its own +// and so that a change to the graph model can't quietly alter what's already +// stored in the index. The mapping between the two lives at the app boundary +// (Phase 2), and the ordinals below must not be renumbered — they're persisted. +enum ChannelBit : int { + ChannelNone = 0, + ChannelAlbedo = 1 << 1, + ChannelNormal = 1 << 2, + ChannelMetalness = 1 << 3, + ChannelRoughness = 1 << 4, + ChannelHeight = 1 << 5, + ChannelAlpha = 1 << 6, + ChannelAO = 1 << 7, +}; + +// One row of the texture table. +// +// Timestamps are Unix milliseconds. 0 means "unset" and is written to the +// database as NULL — the partial index on recents and every `IS NULL` filter +// depend on that distinction, so don't start storing a real 0. +// +// There is deliberately no content hash. Change detection is (file_size, +// file_mtime) from stat(), which is what actually decides whether a file was +// edited outside the app; a hash would only have been a cache key, and the +// cache keys on `id` instead. +struct TextureRecord { + qint64 id = -1; + QString path; + QString name; + qint64 fileSize = 0; + qint64 fileMtime = 0; + int width = 0; + int height = 0; + int nodeCount = 0; + QString libVersion; + int channels = ChannelNone; + qint64 createdAt = 0; + qint64 lastOpened = 0; + qint64 lastSaved = 0; + bool starred = false; + qint64 missingSince = 0; + + bool isValid() const { return id >= 0; } + bool isMissing() const { return missingSince != 0; } + bool hasChannel(ChannelBit bit) const { return (channels & bit) != 0; } +}; + +// Which set of rows a query covers. +enum class Filter { + All, // everything, including missing files (they render dimmed) + Recents, // opened at least once, excluding missing — "jump back in" + Starred, // starred, including missing +}; + +enum class SortKey { + Modified, // file_mtime — what the card's relative time shows + Opened, // last_opened + Name, + Size, +}; + +struct Query { + Filter filter = Filter::All; + SortKey sort = SortKey::Modified; + bool ascending = false; + + // Case-insensitive substring match against name and tags. Empty disables + // the filter entirely rather than matching everything, so the common path + // doesn't pay for the tag subquery. + QString search; + + // limit < 0 means unbounded. The grid pages, so it normally doesn't. + int limit = -1; + int offset = 0; +}; + +} // namespace catalog diff --git a/src/catalog/thumbnailcache.cpp b/src/catalog/thumbnailcache.cpp new file mode 100644 index 00000000..b62a7b1b --- /dev/null +++ b/src/catalog/thumbnailcache.cpp @@ -0,0 +1,328 @@ +#include "thumbnailcache.h" + +#include +#include +#include +#include +#include + +namespace catalog { + +namespace { + +const char* sourceToText(ThumbSource source) +{ + return source == ThumbSource::Save ? "save" : "open"; +} + +Database::Options cacheOptions() +{ + Database::Options options; + // 8 KiB pages suit rows that are mostly a JPEG blob — fewer overflow pages + // per image than the 4 KiB default. Both this and auto_vacuum only take + // effect on an empty file, which is why the cache is recreated rather than + // migrated when anything is wrong with it. + options.pageSize = 8192; + options.incrementalAutoVacuum = true; + options.walMode = true; + options.foreignKeys = false; + return options; +} + +} // namespace + +ThumbnailCache::ThumbnailCache() = default; + +ThumbnailCache::~ThumbnailCache() +{ + close(); +} + +bool ThumbnailCache::open(const QString& path) +{ + close(); + + if (!db.open(path, cacheOptions())) + return recreate(path); + + const bool fresh = db.scalar( + QStringLiteral("SELECT count(*) FROM sqlite_master WHERE type='table'")) + == 0; + + if (fresh) + return createSchema(); + + if (readSchemaVersion() != SchemaVersion) { + qInfo("catalog: thumbnail cache schema mismatch; rebuilding %s", qPrintable(path)); + return recreate(path); + } + + return true; +} + +bool ThumbnailCache::recreate(const QString& path) +{ + db.close(); + + // A cache has no history worth saving, so anything unexpected — a corrupt + // file, an unreadable one, a schema from another build — is resolved by + // starting over. Delete the WAL sidecars too, or SQLite will try to replay + // them into the new file. + QFile::remove(path); + QFile::remove(path + QStringLiteral("-wal")); + QFile::remove(path + QStringLiteral("-shm")); + + if (!db.open(path, cacheOptions())) + return false; + + return createSchema(); +} + +bool ThumbnailCache::createSchema() +{ + QStringList statements; + + statements << QStringLiteral(R"( + CREATE TABLE thumb ( + texture_id INTEGER NOT NULL, + mesh TEXT NOT NULL DEFAULT 'default', + hdri TEXT NOT NULL DEFAULT 'default', + size INTEGER NOT NULL, + format TEXT NOT NULL DEFAULT 'jpg', + source TEXT NOT NULL, + bytes BLOB NOT NULL, + created_at INTEGER NOT NULL, + last_used INTEGER NOT NULL, + PRIMARY KEY (texture_id, mesh, hdri, size) + ) WITHOUT ROWID + )"); + + statements << QStringLiteral("CREATE INDEX ix_evict ON thumb(last_used)"); + statements << QStringLiteral("CREATE TABLE meta (k TEXT PRIMARY KEY, v TEXT)"); + statements << QStringLiteral("INSERT INTO meta (k, v) VALUES ('schema_version', '%1')") + .arg(SchemaVersion); + + return db.execBatch(statements); +} + +int ThumbnailCache::readSchemaVersion() +{ + QSqlQuery query = db.prepare(QStringLiteral("SELECT v FROM meta WHERE k = 'schema_version'")); + if (!query.exec() || !query.next()) + return 0; + + return query.value(0).toInt(); +} + +void ThumbnailCache::close() +{ + db.close(); +} + +bool ThumbnailCache::put(const ThumbKey& key, const QByteArray& bytes, ThumbSource source, + qint64 whenMs) +{ + Entry entry; + entry.key = key; + entry.bytes = bytes; + entry.source = source; + return putBatch({entry}, whenMs); +} + +bool ThumbnailCache::putBatch(const QVector& entries, qint64 whenMs) +{ + if (entries.isEmpty()) + return true; + + Transaction tx(db); + if (!tx.isActive()) + return false; + + for (const Entry& entry : entries) { + if (!entry.key.isValid() || entry.bytes.isEmpty()) { + db.setError(QStringLiteral("refusing to cache an empty or unkeyed thumbnail")); + return false; + } + + // created_at is preserved on conflict so the row keeps its original + // provenance; last_used moves forward because we just produced it. + QSqlQuery query = db.prepare(QStringLiteral(R"( + INSERT INTO thumb (texture_id, mesh, hdri, size, format, source, + bytes, created_at, last_used) + VALUES (?, ?, ?, ?, 'jpg', ?, ?, ?, ?) + ON CONFLICT(texture_id, mesh, hdri, size) DO UPDATE SET + source = excluded.source, + bytes = excluded.bytes, + last_used = excluded.last_used + )")); + + query.addBindValue(entry.key.textureId); + query.addBindValue(entry.key.mesh); + query.addBindValue(entry.key.hdri); + query.addBindValue(entry.key.size); + query.addBindValue(QString::fromLatin1(sourceToText(entry.source))); + query.addBindValue(entry.bytes); + query.addBindValue(whenMs); + query.addBindValue(whenMs); + + if (!query.exec()) { + db.setError(query.lastError().text()); + qWarning("catalog: thumbnail write failed: %s", qPrintable(db.lastError())); + return false; + } + } + + return tx.commit(); +} + +QByteArray ThumbnailCache::get(const ThumbKey& key) const +{ + if (!key.isValid()) + return QByteArray(); + + QSqlQuery query = const_cast(db).prepare(QStringLiteral( + "SELECT bytes FROM thumb WHERE texture_id = ? AND mesh = ? AND hdri = ? AND size = ?")); + query.addBindValue(key.textureId); + query.addBindValue(key.mesh); + query.addBindValue(key.hdri); + query.addBindValue(key.size); + + if (!query.exec() || !query.next()) + return QByteArray(); + + return query.value(0).toByteArray(); +} + +bool ThumbnailCache::contains(const ThumbKey& key) const +{ + if (!key.isValid()) + return false; + + QSqlQuery query = const_cast(db).prepare(QStringLiteral( + "SELECT 1 FROM thumb WHERE texture_id = ? AND mesh = ? AND hdri = ? AND size = ?")); + query.addBindValue(key.textureId); + query.addBindValue(key.mesh); + query.addBindValue(key.hdri); + query.addBindValue(key.size); + + return query.exec() && query.next(); +} + +bool ThumbnailCache::touch(const QVector& keys, qint64 whenMs) +{ + if (keys.isEmpty()) + return true; + + Transaction tx(db); + if (!tx.isActive()) + return false; + + for (const ThumbKey& key : keys) { + QSqlQuery query = db.prepare(QStringLiteral( + "UPDATE thumb SET last_used = ? " + "WHERE texture_id = ? AND mesh = ? AND hdri = ? AND size = ?")); + query.addBindValue(whenMs); + query.addBindValue(key.textureId); + query.addBindValue(key.mesh); + query.addBindValue(key.hdri); + query.addBindValue(key.size); + + if (!query.exec()) { + db.setError(query.lastError().text()); + return false; + } + } + + return tx.commit(); +} + +int ThumbnailCache::removeTexture(qint64 textureId) +{ + if (textureId < 0) + return 0; + + QSqlQuery query = db.prepare(QStringLiteral("DELETE FROM thumb WHERE texture_id = ?")); + query.addBindValue(textureId); + + if (!query.exec()) { + db.setError(query.lastError().text()); + return 0; + } + return query.numRowsAffected(); +} + +qint64 ThumbnailCache::totalBytes() const +{ + return const_cast(db).scalar( + QStringLiteral("SELECT coalesce(sum(length(bytes)), 0) FROM thumb")); +} + +int ThumbnailCache::rowCount() const +{ + return static_cast( + const_cast(db).scalar(QStringLiteral("SELECT count(*) FROM thumb"))); +} + +int ThumbnailCache::evictTo(qint64 budgetBytes) +{ + qint64 total = totalBytes(); + if (total <= budgetBytes) + return 0; + + // Walk oldest-first, deleting until we're under budget. Done in one + // transaction so a crash mid-eviction can't leave a partially reaped cache + // — not that it would matter much, but the free-page accounting below + // assumes the deletes actually landed. + QVector doomed; + qint64 freed = 0; + + { + QSqlQuery scan = db.prepare(QStringLiteral( + "SELECT texture_id, mesh, hdri, size, length(bytes) FROM thumb " + "ORDER BY last_used ASC")); + if (!scan.exec()) { + db.setError(scan.lastError().text()); + return 0; + } + + while (scan.next() && (total - freed) > budgetBytes) { + doomed << scan.value(0) << scan.value(1) << scan.value(2) << scan.value(3); + freed += scan.value(4).toLongLong(); + } + } + + if (doomed.isEmpty()) + return 0; + + Transaction tx(db); + if (!tx.isActive()) + return 0; + + int deleted = 0; + for (int i = 0; i + 3 < doomed.size(); i += 4) { + QSqlQuery query = db.prepare(QStringLiteral( + "DELETE FROM thumb WHERE texture_id = ? AND mesh = ? AND hdri = ? AND size = ?")); + query.addBindValue(doomed[i]); + query.addBindValue(doomed[i + 1]); + query.addBindValue(doomed[i + 2]); + query.addBindValue(doomed[i + 3]); + + if (!query.exec()) { + db.setError(query.lastError().text()); + return 0; + } + deleted += query.numRowsAffected(); + } + + if (!tx.commit()) + return 0; + + // Hand the freed pages back to the filesystem. Incremental rather than a + // full VACUUM so this stays bounded; auto_vacuum was set to INCREMENTAL at + // creation precisely so this call works at all. + db.exec(QStringLiteral("PRAGMA incremental_vacuum")); + + return deleted; +} + +} // namespace catalog diff --git a/src/catalog/thumbnailcache.h b/src/catalog/thumbnailcache.h new file mode 100644 index 00000000..c11814bb --- /dev/null +++ b/src/catalog/thumbnailcache.h @@ -0,0 +1,109 @@ +#pragma once + +#include "database.h" + +#include +#include +#include + +namespace catalog { + +// Identifies one cached image. +// +// Keyed by the index's texture id. An earlier draft keyed on a hash of the +// file's contents so that copies shared a thumbnail and edits invalidated +// themselves — but change detection turned out to be (file_size, file_mtime) +// from stat() either way, and the hash was only ever the key. Dropping it +// removed a dependency and a column for the price of one extra render the +// first time you open a copied file. +// +// The consequence is that this cache is coupled to index.db's row ids: delete +// the index and these rows are orphaned. That's acceptable precisely because +// the cache is disposable — clear it alongside. +struct ThumbKey { + qint64 textureId = -1; + QString mesh = QStringLiteral("default"); + QString hdri = QStringLiteral("default"); + int size = 256; + + bool isValid() const { return textureId >= 0 && size > 0; } +}; + +// Where a cached image came from. Lets a better capture supersede a cheaper one +// instead of the cache locking in whatever was written first. +enum class ThumbSource { + Save, // captured from the 3D viewport at save time — the good one + Open, // captured on open, for a file indexed before this feature existed +}; + +// Repository over thumbs.db — a pure cache. +// +// Deleting this file at any time must be harmless: it is rebuilt as the user +// saves and opens textures. That's why it lives apart from index.db, which +// cannot be rebuilt at all (see LAUNCHER_PRD.md §1.1), and why a schema +// mismatch here is handled by deleting the file rather than migrating it. +class ThumbnailCache { +public: + static constexpr int SchemaVersion = 1; + + // Roughly one 512px and one 256px JPEG per texture, so this is generous. + static constexpr qint64 DefaultBudgetBytes = 512LL * 1024 * 1024; + + ThumbnailCache(); + ~ThumbnailCache(); + + ThumbnailCache(const ThumbnailCache&) = delete; + ThumbnailCache& operator=(const ThumbnailCache&) = delete; + + // Recreates the file from scratch if it's missing, corrupt, or written to a + // different schema version. Only returns false if even that fails. + bool open(const QString& path); + void close(); + + bool isOpen() const { return db.isOpen(); } + QString lastError() const { return db.lastError(); } + + bool put(const ThumbKey& key, const QByteArray& bytes, ThumbSource source, qint64 whenMs); + + // Writes many images in one transaction. One transaction per thumbnail + // means one fsync per thumbnail, which is what makes a bulk write crawl. + struct Entry { + ThumbKey key; + QByteArray bytes; + ThumbSource source = ThumbSource::Save; + }; + bool putBatch(const QVector& entries, qint64 whenMs); + + // Returns an empty QByteArray on a miss. Deliberately does not update + // last_used: this runs during scroll, and a write per painted card is + // exactly what the "no disk writes on the GUI thread" rule forbids. Call + // touch() later with what was actually used. + QByteArray get(const ThumbKey& key) const; + + bool contains(const ThumbKey& key) const; + + // Batched last_used bookkeeping, flushed on idle or close. Day-granularity + // timestamps are plenty for an LRU whose eviction budget is half a gigabyte. + bool touch(const QVector& keys, qint64 whenMs); + + // Drops every variant of one texture. Called when reconciliation sees a + // file's size or mtime change — the cached image no longer shows what's on + // disk — and when a texture is removed from the launcher. + int removeTexture(qint64 textureId); + + qint64 totalBytes() const; + int rowCount() const; + + // Deletes least-recently-used rows until the cache fits in `budgetBytes`, + // then returns pages to the filesystem. Returns the number of rows deleted. + int evictTo(qint64 budgetBytes = DefaultBudgetBytes); + +private: + bool createSchema(); + bool recreate(const QString& path); + int readSchemaVersion(); + + Database db; +}; + +} // namespace catalog diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index fbd0e9fc..1920fccd 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -156,6 +156,18 @@ set(PROJECT_SOURCES ./main.cpp ./telemetry.h ./telemetry.cpp + ./catalogservice.h + ./catalogservice.cpp + ./launcher/launcherwindow.h + ./launcher/launcherwindow.cpp + ./launcher/texturelistmodel.h + ./launcher/texturelistmodel.cpp + ./launcher/texturecarddelegate.h + ./launcher/texturecarddelegate.cpp + ./launcher/texturerowdelegate.h + ./launcher/texturerowdelegate.cpp + ./launcher/launcherformat.h + ./launcher/launcherformat.cpp ./mainwindow.cpp ./mainwindow.h ./clipboard.h @@ -267,6 +279,7 @@ target_link_libraries(texturelab PRIVATE Qt${QT_VERSION_MAJOR}::Widgets nodegraph viewer3d colorpicker + catalog sentry ) diff --git a/src/texturelab/catalogservice.cpp b/src/texturelab/catalogservice.cpp new file mode 100644 index 00000000..973e3767 --- /dev/null +++ b/src/texturelab/catalogservice.cpp @@ -0,0 +1,395 @@ +#include "catalogservice.h" + +#include "models.h" +#include "telemetry.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +qint64 nowMs() +{ + return QDateTime::currentMSecsSinceEpoch(); +} + +// TextureChannel is the app's enum; ChannelBit is the catalog's. They are +// deliberately separate types — see texturerecord.h — so this is the one place +// that knows both. Adding a channel means adding it here and to ChannelBit, +// never renumbering the existing bits. +int channelBitFor(TextureChannel channel) +{ + switch (channel) { + case TextureChannel::Albedo: + return catalog::ChannelAlbedo; + case TextureChannel::Normal: + return catalog::ChannelNormal; + case TextureChannel::Metalness: + return catalog::ChannelMetalness; + case TextureChannel::Roughness: + return catalog::ChannelRoughness; + case TextureChannel::Height: + return catalog::ChannelHeight; + case TextureChannel::Alpha: + return catalog::ChannelAlpha; + case TextureChannel::AO: + return catalog::ChannelAO; + case TextureChannel::None: + break; + } + return catalog::ChannelNone; +} + +// Walks every known path and records what's actually on disk. +// +// Opens its own database connections rather than borrowing the service's: a +// QSqlDatabase belongs to the thread that opened it, and sharing one across +// threads is the kind of bug that shows up as a corrupt read months later. +class ReconcileTask : public QRunnable { +public: + ReconcileTask(CatalogService* owner, const QString& indexPath, const QString& thumbsPath) + : service(owner), indexFile(indexPath), thumbsFile(thumbsPath) + { + setAutoDelete(true); + } + + void run() override + { + catalog::CatalogIndex index; + if (!index.open(indexFile)) { + report(0, 0); + return; + } + if (index.isReadOnly()) { + report(0, 0); + return; + } + + catalog::ThumbnailCache thumbs; + const bool haveThumbs = thumbs.open(thumbsFile); + + int missing = 0; + int updated = 0; + + for (const catalog::TextureRecord& rec : index.allRecords()) { + const QFileInfo info(rec.path); + + if (!info.exists()) { + if (!rec.isMissing()) { + index.markMissing(rec.id, nowMs()); + ++missing; + } + continue; + } + + const qint64 size = info.size(); + const qint64 mtime = info.lastModified().toMSecsSinceEpoch(); + + if (size == rec.fileSize && mtime == rec.fileMtime && !rec.isMissing()) + continue; + + // Size or mtime differ: the file was edited outside the app, so its + // cached thumbnail shows a material that no longer exists. Dropping + // it here is the whole reason this comparison exists — with no + // content hash, nothing else would ever notice. + if (haveThumbs && (size != rec.fileSize || mtime != rec.fileMtime)) + thumbs.removeTexture(rec.id); + + index.markPresent(rec.id, size, mtime); + ++updated; + } + + report(missing, updated); + } + +private: + void report(int missing, int updated) + { + // Back to the GUI thread; the worker's connections are closed by the + // time anyone reacts to this. + QMetaObject::invokeMethod( + service, [owner = service, missing, updated]() { + owner->onReconcileFinished(missing, updated); + }, + Qt::QueuedConnection); + } + + CatalogService* service; + QString indexFile; + QString thumbsFile; +}; + +} // namespace + +CatalogService& CatalogService::instance() +{ + // Heap-allocated and deliberately never deleted. + // + // A function-local static would be destroyed by __run_exit_handlers, which + // runs *after* Qt has torn down its own globals. Closing a QSqlDatabase at + // that point walks a driver registry whose lock has already been destroyed, + // and the process segfaults on exit — after a clean run, which makes it look + // like a crash on quit rather than a teardown-order bug. + // + // shutdown(), called from main() while Qt is still up, is the orderly path; + // leaking this object is what guarantees no destructor runs later. + static CatalogService* service = new CatalogService(); + return *service; +} + +void CatalogService::shutdown() +{ + if (!ready) + return; + + // An in-flight reconciliation holds its own connections and would otherwise + // still be writing while we close ours. + QThreadPool::globalInstance()->waitForDone(5000); + + thumbCache.close(); + catalogIndex.close(); + ready = false; +} + +QString CatalogService::dataDir() +{ + // Matches what Telemetry already does for the crash database, rather than + // the ~/.texturelab the spec sketched — one convention per app, and this + // one is correct on Windows and macOS too. + return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); +} + +QString CatalogService::indexPath() +{ + return dataDir() + QStringLiteral("/index.db"); +} + +QString CatalogService::thumbsPath() +{ + return dataDir() + QStringLiteral("/thumbs.db"); +} + +bool CatalogService::init() +{ + if (ready) + return true; + + QDir().mkpath(dataDir()); + + if (!catalogIndex.open(indexPath())) { + qWarning("catalog: index unavailable (%s); launcher will be empty", + qPrintable(catalogIndex.lastError())); + Telemetry::breadcrumb("catalog", "index open failed"); + return false; + } + + // A cache that won't open is not a reason to fail: every read is allowed to + // miss, and cards fall back to placeholders. + if (!thumbCache.open(thumbsPath())) + qWarning("catalog: thumbnail cache unavailable; cards will show placeholders"); + + ready = true; + + if (!catalogIndex.isReadOnly()) + seedFromRecentFiles(); + + Telemetry::breadcrumb("catalog", "opened index at " + indexPath().toStdString()); + return true; +} + +void CatalogService::seedFromRecentFiles() +{ + // index.db can't be rebuilt by rescanning (LAUNCHER_PRD.md §1.1), so the + // very first launch after this ships would otherwise show an empty window + // to someone with a year of work on disk. The old QSettings recents list is + // the one record of that history we already have. + QSettings settings(QSettings::UserScope, "texturelab", "texturelab"); + if (settings.value("catalogSeeded", false).toBool()) + return; + + const QStringList files = settings.value("recentFiles").toStringList(); + const qint64 now = nowMs(); + int seeded = 0; + + // Ordered most-recent-first with no timestamps, so synthesize descending + // ones a second apart. Preserving the order is the point; the absolute + // values are meaningless and get overwritten on first real open. + for (int i = 0; i < files.size(); ++i) { + const QFileInfo info(files[i]); + if (!info.exists()) + continue; + + catalog::TextureRecord rec; + rec.path = info.absoluteFilePath(); + rec.name = info.completeBaseName(); + rec.fileSize = info.size(); + rec.fileMtime = info.lastModified().toMSecsSinceEpoch(); + + // No metadata: reading it would mean parsing every file at startup, and + // it fills itself in the first time each one is opened. Until then the + // card shows a name and a date, which is what the recents menu showed. + if (catalogIndex.recordOpened(rec, now - qint64(i) * 1000)) + ++seeded; + } + + settings.setValue("catalogSeeded", true); + + if (seeded > 0) { + qInfo("catalog: seeded %d texture(s) from the recent-files list", seeded); + Telemetry::breadcrumb("catalog", "seeded " + std::to_string(seeded) + " from recents"); + emit catalogChanged(); + } +} + +catalog::TextureRecord CatalogService::recordFor(const TextureProjectPtr& project, + const QString& path) +{ + const QFileInfo info(path); + + catalog::TextureRecord rec; + rec.path = info.absoluteFilePath(); + rec.name = info.completeBaseName(); + rec.fileSize = info.size(); + rec.fileMtime = info.lastModified().toMSecsSinceEpoch(); + + if (!project) + return rec; + + // Read from the live project rather than re-parsing the JSON: it's already + // in memory at both write points, and it's the authority on anything the + // file format doesn't store. + rec.width = project->textureWidth; + rec.height = project->textureHeight; + rec.nodeCount = project->nodes.size(); + rec.libVersion = project->libraryVersion; + + int channels = catalog::ChannelNone; + for (auto it = project->textureChannels.begin(); it != project->textureChannels.end(); ++it) { + if (!it.value().isEmpty()) + channels |= channelBitFor(it.key()); + } + rec.channels = channels; + + return rec; +} + +qint64 CatalogService::recordOpened(const TextureProjectPtr& project, const QString& path) +{ + if (!ready || path.isEmpty()) + return -1; + + catalog::TextureRecord rec = recordFor(project, path); + if (!catalogIndex.recordOpened(rec, nowMs())) { + qWarning("catalog: could not record open of %s: %s", qPrintable(path), + qPrintable(catalogIndex.lastError())); + return -1; + } + + emit catalogChanged(); + return rec.id; +} + +qint64 CatalogService::recordSaved(const TextureProjectPtr& project, const QString& path) +{ + if (!ready || path.isEmpty()) + return -1; + + catalog::TextureRecord rec = recordFor(project, path); + if (!catalogIndex.recordSaved(rec, nowMs())) { + qWarning("catalog: could not record save of %s: %s", qPrintable(path), + qPrintable(catalogIndex.lastError())); + return -1; + } + + // The file just changed, so any cached thumbnail is of the old material. + // The caller captures a fresh one straight after this; dropping it here + // means a failed capture leaves a placeholder rather than a stale picture. + thumbCache.removeTexture(rec.id); + + emit catalogChanged(); + return rec.id; +} + +void CatalogService::captureThumbnail(qint64 textureId, const QImage& frame, + catalog::ThumbSource source) +{ + if (!ready || textureId < 0 || frame.isNull()) + return; + + const int side = qMin(frame.width(), frame.height()); + if (side < 32) + return; + + // Center-crop to a square: the viewport is whatever shape the user left the + // dock, and the cards are square. Cropping keeps the material at its own + // scale, where squashing to fit would distort it. + const QImage square = frame.copy((frame.width() - side) / 2, (frame.height() - side) / 2, + side, side); + + QVector entries; + for (int size : {512, 256}) { + const QImage scaled = + square.scaled(size, size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + + QByteArray bytes; + QBuffer buffer(&bytes); + buffer.open(QIODevice::WriteOnly); + if (!scaled.save(&buffer, "JPG", 85)) + continue; + + catalog::ThumbnailCache::Entry entry; + entry.key.textureId = textureId; + entry.key.size = size; + entry.bytes = bytes; + entry.source = source; + entries << entry; + } + + if (entries.isEmpty()) + return; + + if (!thumbCache.putBatch(entries, nowMs())) { + qWarning("catalog: could not cache thumbnail: %s", qPrintable(thumbCache.lastError())); + return; + } + + emit catalogChanged(); +} + +void CatalogService::forget(qint64 textureId) +{ + if (!ready || textureId < 0) + return; + + thumbCache.removeTexture(textureId); + if (catalogIndex.remove(textureId)) + emit catalogChanged(); +} + +void CatalogService::reconcileAsync() +{ + if (!ready || reconcileRunning) + return; + + reconcileRunning = true; + QThreadPool::globalInstance()->start(new ReconcileTask(this, indexPath(), thumbsPath())); +} + +void CatalogService::onReconcileFinished(int missing, int updated) +{ + reconcileRunning = false; + + if (missing > 0 || updated > 0) { + qInfo("catalog: reconcile marked %d missing, refreshed %d", missing, updated); + emit catalogChanged(); + } + + emit reconcileFinished(missing, updated); +} diff --git a/src/texturelab/catalogservice.h b/src/texturelab/catalogservice.h new file mode 100644 index 00000000..b9526e91 --- /dev/null +++ b/src/texturelab/catalogservice.h @@ -0,0 +1,96 @@ +#pragma once + +#include "catalogindex.h" +#include "thumbnailcache.h" + +#include +#include +#include +#include + +class TextureProject; +typedef QSharedPointer TextureProjectPtr; + +// The boundary between the app and the launcher's data layer. +// +// src/catalog/ knows nothing about the node graph on purpose, so everything +// that has to understand a TextureProject — resolution, node count, which +// output channels are wired up, which library version — lives here. Keeping the +// translation in one place means the persisted schema can't drift just because +// the graph model changed. +// +// Owns the process's writable connections to both databases. The reconciliation +// pass opens its own on a worker thread; nothing else may touch these off the +// GUI thread. +class CatalogService : public QObject { + Q_OBJECT + +public: + static CatalogService& instance(); + + // Opens both databases, creating the data directory if needed, and seeds + // the index from the legacy recent-files list on first run. Safe to call + // more than once. Returns false if the index could not be opened — the app + // must still run in that case, just without a launcher. + bool init(); + bool isReady() const { return ready; } + + // Closes both databases while Qt is still alive. Must be called from main() + // after the event loop returns — see the comment on instance(). + void shutdown(); + + static QString dataDir(); + static QString indexPath(); + static QString thumbsPath(); + + catalog::CatalogIndex& index() { return catalogIndex; } + catalog::ThumbnailCache& thumbnails() { return thumbCache; } + + // --- write points (LAUNCHER_PRD.md §6.1) ------------------------------ + + // Both return the index row id, or -1 if the write failed. + qint64 recordOpened(const TextureProjectPtr& project, const QString& path); + qint64 recordSaved(const TextureProjectPtr& project, const QString& path); + + // Stores a thumbnail captured from the 3D viewport (LAUNCHER_PRD.md §4). + // `frame` is the raw grab; it gets center-cropped to a square and written at + // both cache sizes. A null or degenerate image is ignored rather than + // caching a blank card. + void captureThumbnail(qint64 textureId, const QImage& frame, catalog::ThumbSource source); + + // Forgets a texture and its thumbnails. Never touches the file on disk. + void forget(qint64 textureId); + + // --- reconciliation (LAUNCHER_PRD.md §6.2) ---------------------------- + + // stat()s every known path on a worker thread and updates missing/present + // state. Cheap — the row count is bounded by the user's own activity — but + // it runs off the GUI thread anyway, because one unmounted network path + // will block it regardless of how few rows there are. + void reconcileAsync(); + + // Builds the index row for a live project. Public for testing and for + // callers that want the metadata without writing it. + static catalog::TextureRecord recordFor(const TextureProjectPtr& project, const QString& path); + + // Internal: called on the GUI thread by the reconciliation task when it + // finishes. Not for general use. + void onReconcileFinished(int missing, int updated); + +signals: + // Emitted on the GUI thread after any change to the index, so a launcher + // model can refresh itself without polling. + void catalogChanged(); + + void reconcileFinished(int missing, int updated); + +private: + CatalogService() = default; + + void seedFromRecentFiles(); + + catalog::CatalogIndex catalogIndex; + catalog::ThumbnailCache thumbCache; + bool ready = false; + bool reconcileRunning = false; +}; diff --git a/src/texturelab/launcher/launcherformat.cpp b/src/texturelab/launcher/launcherformat.cpp new file mode 100644 index 00000000..1b6ca462 --- /dev/null +++ b/src/texturelab/launcher/launcherformat.cpp @@ -0,0 +1,172 @@ +#include "launcherformat.h" + +#include "texturelistmodel.h" +#include "thememanager.h" +#include "tokens.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace launcherfmt { + +QString relativeTime(qint64 whenMs) +{ + if (whenMs <= 0) + return QStringLiteral("—"); + + const QDateTime when = QDateTime::fromMSecsSinceEpoch(whenMs); + const qint64 secs = when.secsTo(QDateTime::currentDateTime()); + + if (secs < 60) + return QCoreApplication::translate("launcher", "just now"); + if (secs < 3600) + return QStringLiteral("%1m").arg(secs / 60); + if (secs < 86400) + return QStringLiteral("%1h").arg(secs / 3600); + if (secs < 172800) + return QCoreApplication::translate("launcher", "yesterday"); + if (secs < 604800) + return QStringLiteral("%1d").arg(secs / 86400); + if (secs < 2592000) + return QStringLiteral("%1w").arg(secs / 604800); + if (secs < 31536000) + return QStringLiteral("%1mo").arg(secs / 2592000); + return QStringLiteral("%1y").arg(secs / 31536000); +} + +QString resolutionLabel(int width, int height) +{ + if (width <= 0 || height <= 0) + return QString(); + + auto shorten = [](int value) -> QString { + if (value >= 1024 && value % 1024 == 0) + return QStringLiteral("%1K").arg(value / 1024); + return QString::number(value); + }; + + if (width == height) + return shorten(width); + return QStringLiteral("%1×%2").arg(shorten(width), shorten(height)); +} + +QString tooltipFor(const QModelIndex& index, const QString& currentVersionLabel) +{ + if (!index.isValid()) + return QString(); + + QStringList lines; + + // Always the path: with no folder tree in the window, this is the only + // place a texture's location is visible. + lines << index.data(TextureListModel::PathRole).toString(); + + if (index.data(TextureListModel::MissingRole).toBool()) { + lines << QCoreApplication::translate("launcher", + "Not found on disk — open it to locate the file."); + } + + if (index.data(TextureListModel::NeedsMigrationRole).toBool()) { + const QString from = index.data(TextureListModel::LibVersionRole).toString(); + lines << QCoreApplication::translate( + "launcher", "Created with library %1; opening offers an upgrade to %2.") + .arg(from, currentVersionLabel); + } + + const int nodes = index.data(TextureListModel::NodeCountRole).toInt(); + if (nodes > 0) + lines << QCoreApplication::translate("launcher", "%n node(s)", nullptr, nodes); + + return lines.join(QLatin1Char('\n')); +} + +void paintStar(QPainter* painter, const QRectF& box, const QColor& color) +{ + QPainterPath path; + const QPointF center = box.center(); + const double outer = box.width() / 2.0; + const double inner = outer * 0.45; + + for (int i = 0; i < 10; ++i) { + const double radius = (i % 2 == 0) ? outer : inner; + const double angle = -M_PI / 2.0 + i * M_PI / 5.0; + const QPointF point(center.x() + radius * std::cos(angle), + center.y() + radius * std::sin(angle)); + + // moveTo for the first point, not lineTo: a fresh QPainterPath starts at + // the origin, so lineTo here drags a stroke across the whole widget. + if (i == 0) + path.moveTo(point); + else + path.lineTo(point); + } + path.closeSubpath(); + + painter->setPen(Qt::NoPen); + painter->setBrush(color); + painter->drawPath(path); +} + +namespace { + +QFont versionBadgeFont() +{ + QFont font = ThemeManager::instance().theme().font("ui"); + + // Step down in whichever unit the theme actually set — it builds fonts with + // setPixelSize, which leaves pointSize() at -1. + if (font.pixelSize() > 0) + font.setPixelSize(qMax(9, font.pixelSize() - 2)); + else + font.setPointSize(qMax(7, font.pointSize() - 2)); + + return font; +} + +} // namespace + +QSize versionBadgeSize(const QString& version) +{ + if (version.isEmpty()) + return QSize(); + + const QFontMetrics metrics(versionBadgeFont()); + return QSize(metrics.horizontalAdvance(version) + 8, metrics.height() + 2); +} + +void paintVersionBadge(QPainter* painter, const QRect& rect, const QString& version) +{ + if (version.isEmpty() || rect.isEmpty()) + return; + + const Theme& theme = ThemeManager::instance().theme(); + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + + // Translucent dark plate rather than a solid one: it sits on top of the + // thumbnail, and a fully opaque chip punches a hole in the material. + QColor plate = theme.color(Tokens::BgElevated); + plate.setAlpha(185); + + painter->setPen(Qt::NoPen); + painter->setBrush(plate); + + const int radius = qMax(2, theme.radius("sm")); + painter->drawRoundedRect(rect, radius, radius); + + painter->setFont(versionBadgeFont()); + painter->setPen(theme.color(Tokens::LauncherBadge)); + painter->drawText(rect, Qt::AlignCenter | Qt::TextSingleLine, version); + + painter->restore(); +} + +} // namespace launcherfmt diff --git a/src/texturelab/launcher/launcherformat.h b/src/texturelab/launcher/launcherformat.h new file mode 100644 index 00000000..4c7f50a9 --- /dev/null +++ b/src/texturelab/launcher/launcherformat.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +class QColor; +class QPainter; +class QRect; +class QRectF; +class QSize; + +// Presentation helpers shared by the card and row delegates. +// +// This is where the launcher's formatting lives, deliberately outside the +// model: TextureListModel exposes raw values so that two views can present the +// same row differently, and both views agreeing on "2h" and "4K" is a matter of +// sharing this file, not of pushing strings back into the model. +namespace launcherfmt { + +// "just now", "2h", "yesterday", "3d", "1mo" — short enough to sit under a 96px +// card without eliding. Returns an em dash for "never". +QString relativeTime(qint64 whenMs); + +// 4096 -> "4K", 1024 -> "1K", 512 -> "512", non-square -> "2K×1K". Empty when +// the resolution isn't known, which is the case for rows seeded from the old +// recent-files list and not yet opened. +QString resolutionLabel(int width, int height); + +// The full path, plus whatever explains an otherwise cryptic marker on the +// card: the missing state, the migration badge, the node count. +QString tooltipFor(const QModelIndex& index, const QString& currentVersionLabel); + +// A five-pointed star filling `box`. Shared so "starred" reads the same in the +// grid and the list — as a shape, not just a color. The migration badge is a +// dot in the same warn color, and two identical dots side by side in a row say +// nothing. +void paintStar(QPainter* painter, const QRectF& box, const QColor& color); + +// The library-version chip ("v1") shown on textures written by an older node +// library. A bare colored dot said only "something is unusual"; the version +// number says which one, which is the thing worth knowing before opening. +// Size first so the caller can place it, then paint into that rect. +QSize versionBadgeSize(const QString& version); +void paintVersionBadge(QPainter* painter, const QRect& rect, const QString& version); + +} // namespace launcherfmt diff --git a/src/texturelab/launcher/launcherwindow.cpp b/src/texturelab/launcher/launcherwindow.cpp new file mode 100644 index 00000000..1a8377c6 --- /dev/null +++ b/src/texturelab/launcher/launcherwindow.cpp @@ -0,0 +1,654 @@ +#include "launcherwindow.h" + +#include "catalogservice.h" +#include "libraries/libversion.h" +#include "texturecarddelegate.h" +#include "texturelistmodel.h" +#include "texturerowdelegate.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// Object names are the hook for resources/qss/app.qss.in — the launcher adds no +// inline stylesheets (see scripts/check-theme-hygiene.sh). +constexpr const char* kTopBarName = "launcherTopBar"; +constexpr const char* kActionBarName = "launcherActionBar"; +constexpr const char* kGridName = "launcherGrid"; +constexpr const char* kFilterTabName = "launcherFilterTab"; +constexpr const char* kEmptyLabelName = "launcherEmptyLabel"; + +bool isTextureFile(const QUrl& url) +{ + return url.isLocalFile() && url.toLocalFile().endsWith(QStringLiteral(".texture"), + Qt::CaseInsensitive); +} + +} // namespace + +LauncherWindow::LauncherWindow(QWidget* parent) : QWidget(parent) +{ + setWindowTitle(QStringLiteral("TextureLab")); + setMinimumSize(800, 600); + resize(1280, 820); + setAcceptDrops(true); + + model = new TextureListModel(this); + cardDelegate = new TextureCardDelegate(this); + rowDelegate = new TextureRowDelegate(this); + + const QString currentVersion = libVersionToString(currentLibVersion()); + model->setCurrentLibVersion(currentVersion); + cardDelegate->setCurrentVersionLabel(currentVersion); + rowDelegate->setCurrentVersionLabel(currentVersion); + + CatalogService& catalog = CatalogService::instance(); + if (catalog.isReady()) { + model->setIndex(&catalog.index()); + model->setThumbnailCache(&catalog.thumbnails()); + } + + // The index changes from save, open, and the reconciliation pass; the grid + // follows rather than polling. + connect(&catalog, &CatalogService::catalogChanged, this, &LauncherWindow::refresh); + + grid = new QListView(this); + grid->setObjectName(QLatin1String(kGridName)); + grid->setModel(model); + grid->setViewMode(QListView::IconMode); + grid->setResizeMode(QListView::Adjust); + grid->setMovement(QListView::Static); + grid->setUniformItemSizes(true); + grid->setSelectionMode(QAbstractItemView::ExtendedSelection); + grid->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + grid->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + grid->setMouseTracking(true); + grid->setSpacing(6); + grid->setContextMenuPolicy(Qt::CustomContextMenu); + grid->setFrameShape(QFrame::NoFrame); + + connect(grid, &QListView::doubleClicked, this, [this]() { openSelected(); }); + connect(grid, &QWidget::customContextMenuRequested, this, &LauncherWindow::showContextMenu); + + // Sits over the grid rather than replacing it, so switching filters can't + // leave the window structurally empty. + emptyLabel = new QLabel(grid); + emptyLabel->setObjectName(QLatin1String(kEmptyLabelName)); + emptyLabel->setAlignment(Qt::AlignCenter); + emptyLabel->setWordWrap(true); + emptyLabel->hide(); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + layout->addWidget(buildTopBar()); + layout->addWidget(grid, 1); + layout->addWidget(buildActionBar()); + + connect(model, &QAbstractItemModel::modelReset, this, &LauncherWindow::updateEmptyState); + connect(model, &QAbstractItemModel::rowsInserted, this, &LauncherWindow::updateEmptyState); + + // Last, so it can drive widgets the two build* methods created. + restoreViewState(); + updateEmptyState(); +} + +LauncherWindow::~LauncherWindow() = default; + +QWidget* LauncherWindow::buildTopBar() +{ + auto* bar = new QWidget(this); + bar->setObjectName(QLatin1String(kTopBarName)); + + auto* layout = new QHBoxLayout(bar); + layout->setContentsMargins(12, 8, 12, 8); + layout->setSpacing(8); + + // All · Recents · Starred — the saved views, as three text tabs rather than + // a sidebar. + auto* group = new QButtonGroup(bar); + group->setExclusive(true); + + auto makeTab = [&](const QString& text, catalog::Filter filter, bool checked) { + auto* tab = new QToolButton(bar); + tab->setObjectName(QLatin1String(kFilterTabName)); + tab->setText(text); + tab->setCheckable(true); + tab->setChecked(checked); + tab->setCursor(Qt::PointingHandCursor); + group->addButton(tab); + layout->addWidget(tab); + connect(tab, &QToolButton::clicked, this, [this, filter]() { + model->setFilter(filter); + updateEmptyState(); + saveViewState(); + }); + return tab; + }; + + allTab = makeTab(QStringLiteral("All"), catalog::Filter::All, true); + recentsTab = makeTab(QStringLiteral("Recents"), catalog::Filter::Recents, false); + starredTab = makeTab(QStringLiteral("Starred"), catalog::Filter::Starred, false); + + layout->addStretch(1); + + search = new QLineEdit(bar); + search->setPlaceholderText(QStringLiteral("Search…")); + search->setClearButtonEnabled(true); + search->setFixedWidth(240); + connect(search, &QLineEdit::textChanged, this, [this](const QString& text) { + model->setSearchTerm(text); + updateEmptyState(); + }); + layout->addWidget(search); + + // Sort dropdown, parked for now. sortBox stays null while this is commented + // out, and every other use of it is null-guarded, so the launcher just runs + // on the model's default order (Last Modified, newest first). Uncomment to + // bring it back — applySort() and the saved "sort" setting are still wired. + // + // sortBox = new QComboBox(bar); + // sortBox->addItem(QStringLiteral("Last Modified")); + // sortBox->addItem(QStringLiteral("Last Opened")); + // sortBox->addItem(QStringLiteral("Name")); + // sortBox->addItem(QStringLiteral("Size")); + // connect(sortBox, &QComboBox::currentIndexChanged, this, [this](int comboIndex) { + // applySort(comboIndex); + // saveViewState(); + // }); + // layout->addWidget(sortBox); + + auto* gear = new QToolButton(bar); + gear->setText(QStringLiteral("⚙")); + gear->setPopupMode(QToolButton::InstantPopup); + auto* menu = new QMenu(gear); + menu->addAction(QStringLiteral("Clear Missing Textures"), this, [this]() { + CatalogService& catalog = CatalogService::instance(); + if (!catalog.isReady()) + return; + const int removed = catalog.index().removeAllMissing(); + if (removed > 0) + refresh(); + }); + gear->setMenu(menu); + layout->addWidget(gear); + + return bar; +} + +QWidget* LauncherWindow::buildActionBar() +{ + auto* bar = new QWidget(this); + bar->setObjectName(QLatin1String(kActionBarName)); + + auto* layout = new QHBoxLayout(bar); + layout->setContentsMargins(12, 8, 12, 8); + layout->setSpacing(8); + + // ⊞ / ☰ — same model, different delegate. This is the payoff for keeping + // formatting out of the model. + auto* viewGroup = new QButtonGroup(bar); + viewGroup->setExclusive(true); + + auto makeViewToggle = [&](const QString& glyph, const QString& tip, bool checked) { + auto* button = new QToolButton(bar); + button->setObjectName(QLatin1String(kFilterTabName)); + button->setText(glyph); + button->setToolTip(tip); + button->setCheckable(true); + button->setChecked(checked); + button->setCursor(Qt::PointingHandCursor); + viewGroup->addButton(button); + layout->addWidget(button); + return button; + }; + + gridToggle = makeViewToggle(QStringLiteral("⊞"), tr("Grid"), true); + listToggle = makeViewToggle(QStringLiteral("☰"), tr("List"), false); + connect(gridToggle, &QToolButton::clicked, this, [this]() { setGridMode(true); }); + connect(listToggle, &QToolButton::clicked, this, [this]() { setGridMode(false); }); + + sizeSlider = new QSlider(Qt::Horizontal, bar); + sizeSlider->setRange(TextureCardDelegate::MinCardWidth, TextureCardDelegate::MaxCardWidth); + sizeSlider->setValue(cardDelegate->cardWidth()); + sizeSlider->setFixedWidth(120); + connect(sizeSlider, &QSlider::valueChanged, this, [this](int value) { + // setCardWidth emits sizeHintChanged; reset() forces the icon-mode + // layout to actually recompute positions rather than reflow within the + // old grid metrics. + cardDelegate->setCardWidth(value); + if (gridMode) + grid->reset(); + saveViewState(); + }); + layout->addWidget(sizeSlider); + + layout->addStretch(1); + + auto* newButton = new QPushButton(QStringLiteral("New Texture"), bar); + connect(newButton, &QPushButton::clicked, this, &LauncherWindow::newTextureRequested); + layout->addWidget(newButton); + + openButton = new QPushButton(QStringLiteral("Open"), bar); + openButton->setDefault(true); + openButton->setAutoDefault(true); + connect(openButton, &QPushButton::clicked, this, [this]() { openSelected(); }); + layout->addWidget(openButton); + + return bar; +} + +void LauncherWindow::applySort(int comboIndex) +{ + switch (comboIndex) { + case 1: + model->setSort(catalog::SortKey::Opened, false); + break; + case 2: + model->setSort(catalog::SortKey::Name, true); + break; + case 3: + model->setSort(catalog::SortKey::Size, false); + break; + default: + model->setSort(catalog::SortKey::Modified, false); + break; + } +} + +void LauncherWindow::setGridMode(bool useGrid) +{ + gridMode = useGrid; + + if (useGrid) { + grid->setItemDelegate(cardDelegate); + grid->setViewMode(QListView::IconMode); + grid->setSpacing(6); + grid->setWordWrap(false); + } + else { + grid->setItemDelegate(rowDelegate); + grid->setViewMode(QListView::ListMode); + // Rows are separated by their own hairline, so view spacing would only + // break the continuous surface a table wants. + grid->setSpacing(0); + } + + // Icon mode caches item positions; swapping the delegate changes every + // sizeHint, and only a reset makes the view ask again. + grid->reset(); + + if (gridToggle) + gridToggle->setChecked(useGrid); + if (listToggle) + listToggle->setChecked(!useGrid); + if (sizeSlider) + sizeSlider->setEnabled(useGrid); // the row height is fixed + + saveViewState(); +} + +void LauncherWindow::restoreViewState() +{ + QSettings settings; + settings.beginGroup(QStringLiteral("launcher")); + + if (sizeSlider) { + const int width = settings.value(QStringLiteral("cardWidth"), + cardDelegate->cardWidth()).toInt(); + cardDelegate->setCardWidth(width); + QSignalBlocker block(sizeSlider); + sizeSlider->setValue(cardDelegate->cardWidth()); + } + + if (sortBox) { + const int sortIndex = settings.value(QStringLiteral("sort"), 0).toInt(); + if (sortIndex >= 0 && sortIndex < sortBox->count()) { + QSignalBlocker block(sortBox); + sortBox->setCurrentIndex(sortIndex); + applySort(sortIndex); + } + } + + // Filter last: All is the safe default if the stored value is nonsense. + const int filter = settings.value(QStringLiteral("filter"), 0).toInt(); + if (filter == 1 && recentsTab) { + recentsTab->setChecked(true); + model->setFilter(catalog::Filter::Recents); + } + else if (filter == 2 && starredTab) { + starredTab->setChecked(true); + model->setFilter(catalog::Filter::Starred); + } + + setGridMode(settings.value(QStringLiteral("gridMode"), true).toBool()); + + settings.endGroup(); +} + +void LauncherWindow::saveViewState() const +{ + QSettings settings; + settings.beginGroup(QStringLiteral("launcher")); + settings.setValue(QStringLiteral("gridMode"), gridMode); + if (cardDelegate) + settings.setValue(QStringLiteral("cardWidth"), cardDelegate->cardWidth()); + if (sortBox) + settings.setValue(QStringLiteral("sort"), sortBox->currentIndex()); + + int filter = 0; + if (model->filter() == catalog::Filter::Recents) + filter = 1; + else if (model->filter() == catalog::Filter::Starred) + filter = 2; + settings.setValue(QStringLiteral("filter"), filter); + + settings.endGroup(); +} + +void LauncherWindow::refresh() +{ + model->refresh(); + updateEmptyState(); +} + +void LauncherWindow::updateEmptyState() +{ + if (model->totalCount() > 0) { + emptyLabel->hide(); + return; + } + + // Three different nothings, and conflating them is how a first-run window + // ends up looking broken instead of new. + QString message; + if (!model->searchTerm().isEmpty()) { + message = tr("No textures match “%1”").arg(model->searchTerm()); + } + else if (model->filter() == catalog::Filter::Starred) { + message = tr("No starred textures yet.\nStar one from its right-click menu."); + } + else if (model->filter() == catalog::Filter::Recents) { + message = tr("Nothing opened yet."); + } + else { + message = tr("No textures yet.\n\nTextures you create or open will appear here."); + } + + emptyLabel->setText(message); + emptyLabel->resize(grid->viewport()->size()); + emptyLabel->move(0, 0); + emptyLabel->show(); + emptyLabel->raise(); +} + +void LauncherWindow::showEvent(QShowEvent* event) +{ + QWidget::showEvent(event); + refresh(); + search->setFocus(); +} + +void LauncherWindow::openSelected() +{ + const QModelIndexList selection = grid->selectionModel()->selectedIndexes(); + + // With nothing selected, Open falls back to the file dialog. That's also + // how a texture the launcher has never seen gets in, so it must never be + // disabled (LAUNCHER_PRD.md §3.1). + if (selection.isEmpty()) { + emit openDialogRequested(); + return; + } + + const catalog::TextureRecord rec = model->recordAt(selection.first()); + if (rec.path.isEmpty()) + return; + + if (!QFileInfo::exists(rec.path)) { + // Offer to fix it rather than just reporting the problem. Moves aren't + // detected automatically, so this is how a relocated texture keeps its + // stars and history. + const auto answer = QMessageBox::question( + this, tr("Texture Not Found"), + tr("This texture is no longer at:\n%1\n\nIf you moved it, you can point the " + "launcher at its new location.") + .arg(rec.path), + QMessageBox::Cancel | QMessageBox::Open, QMessageBox::Open); + + if (answer == QMessageBox::Open) + locate(rec); + return; + } + + emit openPathRequested(rec.path); +} + +void LauncherWindow::locate(const catalog::TextureRecord& rec) +{ + CatalogService& catalog = CatalogService::instance(); + if (!catalog.isReady()) + return; + + // Start where it used to live: a moved file is usually a sibling of its old + // home, or the user at least remembers the neighbourhood. + const QString startDir = QFileInfo(rec.path).absolutePath(); + + const QString chosen = QFileDialog::getOpenFileName( + this, tr("Locate “%1”").arg(rec.name), startDir, + tr("Texturelab File (*.texture)")); + + if (chosen.isEmpty()) + return; + + const QFileInfo info(chosen); + const qint64 survivor = catalog.index().relocate(rec.id, info.absoluteFilePath(), info.size(), + info.lastModified().toMSecsSinceEpoch()); + + if (survivor < 0) { + QMessageBox::warning(this, tr("Locate Texture"), + tr("Could not update the launcher entry:\n%1") + .arg(catalog.index().lastError())); + return; + } + + // The file at the new path may be different content entirely, so the old + // thumbnail can't be trusted. It regenerates on the next open or save. + catalog.thumbnails().removeTexture(survivor); + + refresh(); + + const QModelIndex found = model->indexForId(survivor); + if (found.isValid()) { + grid->setCurrentIndex(found); + grid->scrollTo(found); + } +} + +void LauncherWindow::showContextMenu(const QPoint& pos) +{ + const QModelIndex index = grid->indexAt(pos); + if (!index.isValid()) + return; + + if (!grid->selectionModel()->isSelected(index)) + grid->setCurrentIndex(index); + + const catalog::TextureRecord rec = model->recordAt(index); + + QMenu menu(this); + menu.addAction(tr("Open"), this, [this]() { openSelected(); }); + if (rec.isMissing()) + menu.addAction(tr("Locate…"), this, [this, rec]() { locate(rec); }); + menu.addSeparator(); + menu.addAction(rec.starred ? tr("Unstar") : tr("Star"), this, + [this]() { toggleStarOnSelection(); }); + menu.addAction(tr("Show in File Manager"), this, [rec]() { + // Opens the containing directory; selecting the file itself needs + // per-platform shell calls that aren't worth it here. + QDesktopServices::openUrl(QUrl::fromLocalFile(QFileInfo(rec.path).absolutePath())); + }); + menu.addSeparator(); + menu.addAction(tr("Remove from Launcher"), this, + [this]() { removeSelectionFromLauncher(); }); + + menu.exec(grid->viewport()->mapToGlobal(pos)); +} + +void LauncherWindow::toggleStarOnSelection() +{ + CatalogService& catalog = CatalogService::instance(); + if (!catalog.isReady()) + return; + + const QModelIndexList selection = grid->selectionModel()->selectedIndexes(); + if (selection.isEmpty()) + return; + + // One toggle for the whole selection, driven by the first item, so a + // multi-select doesn't half-star and half-unstar. + const bool starred = model->recordAt(selection.first()).starred; + for (const QModelIndex& index : selection) { + const catalog::TextureRecord rec = model->recordAt(index); + if (rec.isValid()) + catalog.index().setStarred(rec.id, !starred); + } + + refresh(); +} + +void LauncherWindow::removeSelectionFromLauncher() +{ + CatalogService& catalog = CatalogService::instance(); + if (!catalog.isReady()) + return; + + const QModelIndexList selection = grid->selectionModel()->selectedIndexes(); + if (selection.isEmpty()) + return; + + const auto answer = QMessageBox::question( + this, tr("Remove from Launcher"), + tr("Remove %n texture(s) from the launcher?\n\nThe files stay on disk — this only " + "forgets them here.", + nullptr, int(selection.size())), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + + if (answer != QMessageBox::Yes) + return; + + for (const QModelIndex& index : selection) { + const catalog::TextureRecord rec = model->recordAt(index); + if (rec.isValid()) + catalog.forget(rec.id); + } + + refresh(); +} + +void LauncherWindow::setOpenPath(const QString& path) +{ + model->setOpenPath(path); +} + +void LauncherWindow::setHasDocument(bool value) +{ + hasDocument = value; +} + +void LauncherWindow::keyPressEvent(QKeyEvent* event) +{ + if (event->matches(QKeySequence::Find)) { + search->setFocus(); + search->selectAll(); + event->accept(); + return; + } + + if (event->key() == Qt::Key_Escape) { + // Clear the search first if there is one; only then close. And never + // close when there's no document behind us — that would leave the user + // staring at nothing. + if (!search->text().isEmpty()) { + search->clear(); + event->accept(); + return; + } + if (hasDocument) + emit closeRequested(); + event->accept(); + return; + } + + if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) { + openSelected(); + event->accept(); + return; + } + + QWidget::keyPressEvent(event); +} + +void LauncherWindow::closeEvent(QCloseEvent* event) +{ + if (!hasDocument) { + // The window manager's close button on first launch means "quit", not + // "show me the empty editor behind this". + event->accept(); + return; + } + + event->ignore(); + emit closeRequested(); +} + +void LauncherWindow::dragEnterEvent(QDragEnterEvent* event) +{ + if (!event->mimeData()->hasUrls()) { + event->ignore(); + return; + } + + for (const QUrl& url : event->mimeData()->urls()) { + if (isTextureFile(url)) { + event->acceptProposedAction(); + return; + } + } + event->ignore(); +} + +void LauncherWindow::dropEvent(QDropEvent* event) +{ + // Dropping a .texture adds it and opens it — the explicit recovery path if + // index.db is ever lost (LAUNCHER_PRD.md §1.1). + for (const QUrl& url : event->mimeData()->urls()) { + if (isTextureFile(url)) { + event->acceptProposedAction(); + emit openPathRequested(url.toLocalFile()); + return; + } + } + event->ignore(); +} diff --git a/src/texturelab/launcher/launcherwindow.h b/src/texturelab/launcher/launcherwindow.h new file mode 100644 index 00000000..51255ce3 --- /dev/null +++ b/src/texturelab/launcher/launcherwindow.h @@ -0,0 +1,92 @@ +#pragma once + +#include "texturerecord.h" + +#include + +class QComboBox; +class QLabel; +class QLineEdit; +class QListView; +class QPushButton; +class QSlider; +class QToolButton; + +class TextureCardDelegate; +class TextureListModel; +class TextureRowDelegate; + +// The launcher: a flat grid of every texture the app has touched. +// +// Knows nothing about MainWindow. It emits what the user asked for and lets the +// caller decide how to honor it — which is what keeps "Open" able to run through +// promptSaveIfDirty() without this window having to know that dirty documents +// are a concept. +class LauncherWindow : public QWidget { + Q_OBJECT + +public: + explicit LauncherWindow(QWidget* parent = nullptr); + ~LauncherWindow() override; + + // Marks the document currently loaded in the editor, for the card's "open" + // dot. Empty when nothing is loaded. + void setOpenPath(const QString& path); + + // True once a document exists behind the launcher, which is what makes Esc + // and the close button meaningful — on first launch there is nowhere to go. + void setHasDocument(bool hasDocument); + +signals: + void newTextureRequested(); + void openPathRequested(const QString& path); + void openDialogRequested(); + void closeRequested(); + +public slots: + void refresh(); + +protected: + void keyPressEvent(QKeyEvent* event) override; + void closeEvent(QCloseEvent* event) override; + void dragEnterEvent(QDragEnterEvent* event) override; + void dropEvent(QDropEvent* event) override; + void showEvent(QShowEvent* event) override; + +private: + QWidget* buildTopBar(); + QWidget* buildActionBar(); + void applySort(int comboIndex); + void setGridMode(bool grid); + void restoreViewState(); + void saveViewState() const; + void updateEmptyState(); + void openSelected(); + + // Asks the user where a missing texture went and re-points its index row, + // keeping stars, tags, and recency. + void locate(const catalog::TextureRecord& rec); + + void showContextMenu(const QPoint& pos); + void toggleStarOnSelection(); + void removeSelectionFromLauncher(); + + TextureListModel* model = nullptr; + TextureCardDelegate* cardDelegate = nullptr; + TextureRowDelegate* rowDelegate = nullptr; + + QListView* grid = nullptr; + QLineEdit* search = nullptr; + QComboBox* sortBox = nullptr; + QSlider* sizeSlider = nullptr; + QLabel* emptyLabel = nullptr; + QPushButton* openButton = nullptr; + QToolButton* gridToggle = nullptr; + QToolButton* listToggle = nullptr; + QToolButton* allTab = nullptr; + QToolButton* recentsTab = nullptr; + QToolButton* starredTab = nullptr; + + bool hasDocument = false; + bool gridMode = true; +}; diff --git a/src/texturelab/launcher/texturecarddelegate.cpp b/src/texturelab/launcher/texturecarddelegate.cpp new file mode 100644 index 00000000..cd8247ab --- /dev/null +++ b/src/texturelab/launcher/texturecarddelegate.cpp @@ -0,0 +1,260 @@ +#include "texturecarddelegate.h" + +#include "launcherformat.h" +#include "texturelistmodel.h" +#include "texturerecord.h" +#include "thememanager.h" +#include "tokens.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +QColor tc(const char* token) +{ + return ThemeManager::instance().theme().color(token); +} + +QColor tc(const char* token, int alpha) +{ + QColor color = ThemeManager::instance().theme().color(token); + color.setAlpha(alpha); + return color; +} + +int themeRadius() +{ + const int r = ThemeManager::instance().theme().radius("sm"); + return r > 0 ? r : 3; +} + +int themeSpace(const char* key, int fallback) +{ + const int s = ThemeManager::instance().theme().space(key); + return s > 0 ? s : fallback; +} + +// The channel pips, in a fixed order so a card's silhouette is recognizable at +// a glance rather than shuffling with whatever the graph happens to define. +const int kPipOrder[] = { + catalog::ChannelAlbedo, catalog::ChannelNormal, catalog::ChannelRoughness, + catalog::ChannelHeight, catalog::ChannelMetalness, catalog::ChannelAO, +}; +constexpr int kPipCount = int(sizeof(kPipOrder) / sizeof(kPipOrder[0])); + +} // namespace + +TextureCardDelegate::TextureCardDelegate(QObject* parent) : QStyledItemDelegate(parent) {} + +void TextureCardDelegate::setCardWidth(int width) +{ + const int clamped = qBound(MinCardWidth, width, MaxCardWidth); + if (clamped == cardW) + return; + + cardW = clamped; + + // Tells the view its cached geometry is stale. An invalid index means "all + // of them", which is exactly the case when the card size changes. + emit sizeHintChanged(QModelIndex()); +} + +int TextureCardDelegate::textBlockHeight() const +{ + // Name, a gap, then "modified · resolution". The gap is load-bearing: with + // the two lines flush, a name ending in an underscore paints its glyph onto + // the metadata line below. + const QFontMetrics metrics(ThemeManager::instance().theme().font("ui")); + return metrics.lineSpacing() * 2 + themeSpace("sm", 4) * 3; +} + +QSize TextureCardDelegate::sizeHint(const QStyleOptionViewItem&, const QModelIndex&) const +{ + return QSize(cardW, cardW + textBlockHeight()); +} + +void TextureCardDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const +{ + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + + const bool selected = option.state & QStyle::State_Selected; + const bool hovered = option.state & QStyle::State_MouseOver; + const bool missing = index.data(TextureListModel::MissingRole).toBool(); + const bool starred = index.data(TextureListModel::StarredRole).toBool(); + const bool isOpen = index.data(TextureListModel::IsOpenRole).toBool(); + const bool needsMigration = index.data(TextureListModel::NeedsMigrationRole).toBool(); + + const int pad = themeSpace("sm", 4); + const int radius = themeRadius(); + + QRect card = option.rect.adjusted(pad, pad, -pad, -pad); + const int thumbSide = card.width(); + QRect thumb(card.left(), card.top(), thumbSide, thumbSide); + + // Card body. Selection is a border, never a fill — the material renders are + // supposed to be the only saturated thing on screen. + painter->setPen(Qt::NoPen); + painter->setBrush(hovered ? tc(Tokens::LauncherCardHover) : tc(Tokens::LauncherCard)); + painter->drawRoundedRect(card, radius, radius); + + // Thumbnail well. Drawn even when there's an image, so the rounded corners + // and the "no preview yet" state share one shape. + painter->setBrush(tc(Tokens::LauncherThumbBg)); + painter->drawRoundedRect(thumb, radius, radius); + + const QPixmap preview = index.data(TextureListModel::ThumbnailRole).value(); + if (!preview.isNull()) { + painter->save(); + + // Clip to the well so the capture inherits its rounded corners instead + // of painting square over them. + QPainterPath clip; + clip.addRoundedRect(thumb, radius, radius); + painter->setClipPath(clip); + + // The stored image is square and so is the well, but the card can be + // resized to anything — scale to cover and center rather than letting a + // rounding difference letterbox it. + const QPixmap scaled = preview.scaled(thumb.size(), Qt::KeepAspectRatioByExpanding, + Qt::SmoothTransformation); + painter->drawPixmap(thumb.center() - QPoint(scaled.width() / 2, scaled.height() / 2), + scaled); + painter->restore(); + } + + if (missing) + painter->setOpacity(0.4); + + // Channel pips along the bottom of the thumbnail. + const int channels = index.data(TextureListModel::ChannelsRole).toInt(); + if (channels != catalog::ChannelNone) { + const int pipSize = qMax(3, thumbSide / 32); + const int gap = pipSize; + const int totalWidth = kPipCount * pipSize + (kPipCount - 1) * gap; + int x = thumb.left() + (thumb.width() - totalWidth) / 2; + const int y = thumb.bottom() - pipSize - pad; + + painter->setPen(Qt::NoPen); + for (int i = 0; i < kPipCount; ++i) { + const bool on = (channels & kPipOrder[i]) != 0; + painter->setBrush(on ? tc(Tokens::LauncherPipOn) : tc(Tokens::LauncherPipOff, 120)); + painter->drawEllipse(QRect(x, y, pipSize, pipSize)); + x += pipSize + gap; + } + } + + painter->setOpacity(1.0); + + // Text block. + const QFont uiFont = ThemeManager::instance().theme().font("ui"); + + // Bounded by the card, not by textBlockHeight(): the latter is the sizeHint + // budget and overruns the card body by one pad, which clips the descenders + // of the meta line against the border. + QRect textRect(card.left() + pad, thumb.bottom() + pad, card.width() - pad * 2, + card.bottom() - thumb.bottom() - pad * 2); + + QFont nameFont = uiFont; + QFontMetrics nameMetrics(nameFont); + nameFont.setStrikeOut(missing); + painter->setFont(nameFont); + painter->setPen(missing ? tc(Tokens::TextDisabled) : tc(Tokens::TextPrimary)); + + const QString name = index.data(TextureListModel::NameRole).toString(); + // lineSpacing(), not height(): height() ends flush with the descent, and a + // name containing an underscore then paints its glyph right on the meta + // line below, which reads as a stray dash next to the metadata. + QRect nameRect(textRect.left(), textRect.top(), textRect.width(), nameMetrics.lineSpacing()); + // TextSingleLine: without it drawText() may lay the string out as wrapped + // rich-ish text in a rect this short, which leaves a clipped fragment of the + // overflow visible as a stray mark next to the line. + painter->drawText(nameRect, Qt::AlignLeft | Qt::AlignVCenter | Qt::TextSingleLine, + nameMetrics.elidedText(name, Qt::ElideMiddle, nameRect.width())); + + // Secondary line: "2h · 4K", or the missing marker, which matters more than + // either of them. + QFont metaFont = uiFont; + // The theme builds fonts with setPixelSize (thememanager.cpp), so + // pointSize() returns -1 on them. Subtracting from that silently yields a + // 6pt font — small enough that the renderer leaves a stray hinting artifact + // past the end of the string. Step down in whichever unit is actually set. + if (uiFont.pixelSize() > 0) + metaFont.setPixelSize(qMax(9, uiFont.pixelSize() - 1)); + else + metaFont.setPointSize(qMax(7, uiFont.pointSize() - 1)); + painter->setFont(metaFont); + painter->setPen(tc(Tokens::TextSecondary)); + + QString meta; + if (missing) { + meta = QStringLiteral("missing"); + } + else { + meta = launcherfmt::relativeTime(index.data(TextureListModel::ModifiedRole).toLongLong()); + const QString resolution = launcherfmt::resolutionLabel( + index.data(TextureListModel::WidthRole).toInt(), + index.data(TextureListModel::HeightRole).toInt()); + if (!resolution.isEmpty()) + meta += QStringLiteral(" · ") + resolution; + } + + const QFontMetrics metaMetrics(metaFont); + QRect metaRect(textRect.left(), nameRect.bottom() + themeSpace("sm", 4) / 2, textRect.width(), + metaMetrics.height()); + painter->drawText(metaRect, Qt::AlignLeft | Qt::AlignVCenter | Qt::TextSingleLine, + metaMetrics.elidedText(meta, Qt::ElideRight, metaRect.width())); + + + // Overlays on the thumbnail corners. + const int markSize = qMax(10, thumbSide / 10); + + if (starred) + launcherfmt::paintStar(painter, QRectF(thumb.right() - markSize - pad, thumb.top() + pad, markSize, + markSize), + tc(Tokens::LauncherStar)); + + if (isOpen) { + painter->setPen(Qt::NoPen); + painter->setBrush(tc(Tokens::LauncherOpenDot)); + painter->drawEllipse(QRect(thumb.left() + pad, thumb.top() + pad, markSize / 2, + markSize / 2)); + } + + if (needsMigration) { + // The version itself, not an anonymous marker: "v1" tells you which + // library wrote this and therefore what the upgrade prompt will offer. + const QString version = index.data(TextureListModel::LibVersionRole).toString(); + const QSize badge = launcherfmt::versionBadgeSize(version); + if (!badge.isEmpty()) { + launcherfmt::paintVersionBadge( + painter, + QRect(QPoint(thumb.left() + pad, thumb.bottom() - badge.height() - pad), badge), + version); + } + } + + // Borders last, so nothing paints over them. + painter->setBrush(Qt::NoBrush); + painter->setPen(QPen(selected ? tc(Tokens::Accent) : tc(Tokens::LauncherCardBorder), 1)); + painter->drawRoundedRect(card.adjusted(0, 0, -1, -1), radius, radius); + + painter->restore(); +} + +bool TextureCardDelegate::helpEvent(QHelpEvent* event, QAbstractItemView* view, + const QStyleOptionViewItem& option, const QModelIndex& index) +{ + if (!event || !view || !index.isValid()) + return QStyledItemDelegate::helpEvent(event, view, option, index); + + QToolTip::showText(event->globalPos(), + launcherfmt::tooltipFor(index, currentVersionLabel), view); + return true; +} diff --git a/src/texturelab/launcher/texturecarddelegate.h b/src/texturelab/launcher/texturecarddelegate.h new file mode 100644 index 00000000..82b9b2a9 --- /dev/null +++ b/src/texturelab/launcher/texturecarddelegate.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +// Paints one texture card: square thumbnail, name, and a "modified · resolution" +// line, with channel pips along the bottom of the thumbnail. +// +// All colors come from the theme at paint time, so --dev-theme hot-reload works +// without a rebuild. No QColor literals here — see scripts/check-theme-hygiene.sh. +class TextureCardDelegate : public QStyledItemDelegate { + Q_OBJECT + +public: + explicit TextureCardDelegate(QObject* parent = nullptr); + + // Card width in pixels; the thumbnail is square, so height follows. Driven + // by the action bar's size slider. + void setCardWidth(int width); + int cardWidth() const { return cardW; } + + static constexpr int MinCardWidth = 96; + static constexpr int MaxCardWidth = 256; + + // Shown in the migration tooltip as the version a file would be upgraded + // to. Injected so the delegate keeps no dependency on the node library. + void setCurrentVersionLabel(const QString& label) { currentVersionLabel = label; } + + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + + // Builds the tooltip from roles rather than letting the model return a + // display string — the model stays formatting-free so a second view can + // present the same data differently. + bool helpEvent(QHelpEvent* event, QAbstractItemView* view, + const QStyleOptionViewItem& option, const QModelIndex& index) override; + QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override; + +private: + int textBlockHeight() const; + + int cardW = 160; + QString currentVersionLabel; +}; diff --git a/src/texturelab/launcher/texturelistmodel.cpp b/src/texturelab/launcher/texturelistmodel.cpp new file mode 100644 index 00000000..141a833d --- /dev/null +++ b/src/texturelab/launcher/texturelistmodel.cpp @@ -0,0 +1,255 @@ +#include "texturelistmodel.h" + +#include "catalogindex.h" +#include "thumbnailcache.h" + +#include + +TextureListModel::TextureListModel(QObject* parent) : QAbstractListModel(parent) +{ + query.filter = catalog::Filter::All; + query.sort = catalog::SortKey::Modified; + query.ascending = false; + query.limit = PageSize; + query.offset = 0; +} + +void TextureListModel::setIndex(catalog::CatalogIndex* index) +{ + catalogIndex = index; + reload(); +} + +void TextureListModel::setThumbnailCache(catalog::ThumbnailCache* cache) +{ + thumbnails = cache; + pixmaps.clear(); + + if (!rows.isEmpty()) + emit dataChanged(index(0), index(rows.size() - 1), {ThumbnailRole}); +} + +void TextureListModel::reload() +{ + beginResetModel(); + rows.clear(); + // Dropped wholesale rather than selectively: a reload follows a save or a + // reconcile, either of which may have replaced any card's image. + pixmaps.clear(); + total = 0; + + if (catalogIndex && catalogIndex->isOpen()) { + catalog::Query page = query; + page.limit = PageSize; + page.offset = 0; + rows = catalogIndex->list(page); + total = catalogIndex->count(query); + } + + endResetModel(); +} + +void TextureListModel::refresh() +{ + // Deliberately a full reset rather than a diff. The alternative is + // reconciling two ordered sets to emit precise row moves, which is a real + // source of off-by-one crashes, and the launcher only refreshes on discrete + // user actions — never mid-scroll. + reload(); +} + +int TextureListModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) + return 0; + return rows.size(); +} + +bool TextureListModel::canFetchMore(const QModelIndex& parent) const +{ + if (parent.isValid()) + return false; + return rows.size() < total; +} + +void TextureListModel::fetchMore(const QModelIndex& parent) +{ + if (parent.isValid() || !catalogIndex || !catalogIndex->isOpen()) + return; + + catalog::Query page = query; + page.limit = PageSize; + page.offset = rows.size(); + + const QVector more = catalogIndex->list(page); + if (more.isEmpty()) { + // The table shrank under us (rows removed elsewhere). Trust what we + // actually have rather than looping on a stale total. + total = rows.size(); + return; + } + + beginInsertRows(QModelIndex(), rows.size(), rows.size() + more.size() - 1); + rows += more; + endInsertRows(); +} + +void TextureListModel::setFilter(catalog::Filter filter) +{ + if (query.filter == filter) + return; + query.filter = filter; + reload(); +} + +void TextureListModel::setSort(catalog::SortKey key, bool ascending) +{ + if (query.sort == key && query.ascending == ascending) + return; + query.sort = key; + query.ascending = ascending; + reload(); +} + +void TextureListModel::setSearchTerm(const QString& term) +{ + if (query.search == term) + return; + query.search = term; + reload(); +} + +void TextureListModel::setOpenPath(const QString& path) +{ + if (openPath == path) + return; + + openPath = path; + if (!rows.isEmpty()) + emit dataChanged(index(0), index(rows.size() - 1), {IsOpenRole}); +} + +void TextureListModel::setCurrentLibVersion(const QString& version) +{ + if (currentVersion == version) + return; + + currentVersion = version; + if (!rows.isEmpty()) + emit dataChanged(index(0), index(rows.size() - 1), {NeedsMigrationRole}); +} + +catalog::TextureRecord TextureListModel::recordAt(const QModelIndex& index) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= rows.size()) + return catalog::TextureRecord(); + return rows.at(index.row()); +} + +QModelIndex TextureListModel::indexForId(qint64 id) const +{ + for (int row = 0; row < rows.size(); ++row) { + if (rows.at(row).id == id) + return index(row); + } + return QModelIndex(); +} + +QVariant TextureListModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= rows.size()) + return QVariant(); + + const catalog::TextureRecord& rec = rows.at(index.row()); + + switch (role) { + case Qt::DisplayRole: + case NameRole: + return rec.name; + case Qt::ToolTipRole: + // With no folder tree, the tooltip is where location lives. + return rec.path; + case IdRole: + return rec.id; + case PathRole: + return rec.path; + case ModifiedRole: + return rec.fileMtime; + case OpenedRole: + return rec.lastOpened; + case SavedRole: + return rec.lastSaved; + case FileSizeRole: + return rec.fileSize; + case WidthRole: + return rec.width; + case HeightRole: + return rec.height; + case NodeCountRole: + return rec.nodeCount; + case ChannelsRole: + return rec.channels; + case LibVersionRole: + return rec.libVersion; + case StarredRole: + return rec.starred; + case MissingRole: + return rec.isMissing(); + case NeedsMigrationRole: + // Seeded rows have no version recorded yet — don't badge those as + // outdated when we simply haven't looked inside the file. Same when the + // caller never told us what "current" is. + if (rec.libVersion.isEmpty() || currentVersion.isEmpty()) + return false; + return rec.libVersion.compare(currentVersion, Qt::CaseInsensitive) != 0; + case IsOpenRole: + return !openPath.isEmpty() && rec.path == openPath; + case ThumbnailRole: { + auto cached = pixmaps.constFind(rec.id); + if (cached != pixmaps.constEnd()) + return *cached; + + QPixmap pixmap; + if (thumbnails && thumbnails->isOpen()) { + catalog::ThumbKey key; + key.textureId = rec.id; + key.size = 256; + const QByteArray bytes = thumbnails->get(key); + if (!bytes.isEmpty()) + pixmap.loadFromData(bytes, "JPG"); + } + + // A null pixmap is cached too — it means "asked and there wasn't one", + // and without it every repaint re-queries the database for a miss. + pixmaps.insert(rec.id, pixmap); + return pixmap; + } + case RecordRole: + return QVariant::fromValue(rec); + default: + return QVariant(); + } +} + +QHash TextureListModel::roleNames() const +{ + QHash names = QAbstractListModel::roleNames(); + names[IdRole] = "textureId"; + names[NameRole] = "name"; + names[PathRole] = "path"; + names[ModifiedRole] = "modified"; + names[OpenedRole] = "opened"; + names[SavedRole] = "saved"; + names[FileSizeRole] = "fileSize"; + names[WidthRole] = "width"; + names[HeightRole] = "height"; + names[NodeCountRole] = "nodeCount"; + names[ChannelsRole] = "channels"; + names[LibVersionRole] = "libVersion"; + names[StarredRole] = "starred"; + names[MissingRole] = "missing"; + names[NeedsMigrationRole] = "needsMigration"; + names[IsOpenRole] = "isOpen"; + names[ThumbnailRole] = "thumbnail"; + return names; +} diff --git a/src/texturelab/launcher/texturelistmodel.h b/src/texturelab/launcher/texturelistmodel.h new file mode 100644 index 00000000..1d6b97c2 --- /dev/null +++ b/src/texturelab/launcher/texturelistmodel.h @@ -0,0 +1,116 @@ +#pragma once + +#include "texturerecord.h" + +#include +#include +#include +#include + +namespace catalog { +class CatalogIndex; +class ThumbnailCache; +} + +// The launcher's model over the catalog index. +// +// View-agnostic on purpose: it exposes typed roles and no formatting. The grid +// delegate and the eventual list delegate both read the same roles, and neither +// the "2h ago" string nor the "4K" string is built here — a model that formats +// is a model that can only feed one view. +// +// Rows are paged in with canFetchMore()/fetchMore() rather than loaded whole, so +// first paint costs one small query no matter how much history has accumulated. +class TextureListModel : public QAbstractListModel { + Q_OBJECT + +public: + enum Roles { + IdRole = Qt::UserRole + 1, + NameRole, + PathRole, + ModifiedRole, // qint64 ms — file mtime + OpenedRole, // qint64 ms, 0 if never + SavedRole, // qint64 ms, 0 if never + FileSizeRole, // qint64 bytes + WidthRole, + HeightRole, + NodeCountRole, + ChannelsRole, // catalog::ChannelBit mask + LibVersionRole, + StarredRole, + MissingRole, + NeedsMigrationRole, + IsOpenRole, // currently loaded in the editor + ThumbnailRole, // QPixmap; null when nothing has been captured yet + RecordRole, // the whole catalog::TextureRecord + }; + + // Rows fetched per batch. Comfortably more than fills a 1280×820 window, so + // the first screen never waits on a second query. + static constexpr int PageSize = 200; + + explicit TextureListModel(QObject* parent = nullptr); + + // The index is borrowed, not owned, and must outlive the model. + void setIndex(catalog::CatalogIndex* index); + + // Optional. Without it every card paints a placeholder, which is the + // correct degraded state when the cache can't be opened. + void setThumbnailCache(catalog::ThumbnailCache* cache); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role) const override; + QHash roleNames() const override; + + bool canFetchMore(const QModelIndex& parent) const override; + void fetchMore(const QModelIndex& parent) override; + + // Total matching rows, including any not yet fetched. The empty-state and + // result-count copy needs this, not rowCount(). + int totalCount() const { return total; } + + catalog::Filter filter() const { return query.filter; } + void setFilter(catalog::Filter filter); + + void setSort(catalog::SortKey key, bool ascending); + catalog::SortKey sortKey() const { return query.sort; } + bool sortAscending() const { return query.ascending; } + + QString searchTerm() const { return query.search; } + void setSearchTerm(const QString& term); + + // Marks which texture is loaded in the editor, for the "currently open" + // indicator. Exactly one, since the app is single-document. + void setOpenPath(const QString& path); + + // The library version rows are compared against for the migration badge. + // Injected rather than read from libraries/libversion.h so this model + // depends on nothing but Qt and the catalog, and can be tested headless. + void setCurrentLibVersion(const QString& version); + + catalog::TextureRecord recordAt(const QModelIndex& index) const; + QModelIndex indexForId(qint64 id) const; + +public slots: + // Re-runs the query from scratch, keeping the first page's worth of rows. + // Called whenever the catalog changes underneath us. + void refresh(); + +private: + void reload(); + + catalog::CatalogIndex* catalogIndex = nullptr; + catalog::Query query; + QVector rows; + + // Decoded pixmaps, keyed by texture id. JPEG decoding during scroll would + // be visible, and the same card is repainted constantly — on hover, on + // selection, on every scroll pixel. + mutable QHash pixmaps; + + catalog::ThumbnailCache* thumbnails = nullptr; + QString openPath; + QString currentVersion; + int total = 0; +}; diff --git a/src/texturelab/launcher/texturerowdelegate.cpp b/src/texturelab/launcher/texturerowdelegate.cpp new file mode 100644 index 00000000..f4a75857 --- /dev/null +++ b/src/texturelab/launcher/texturerowdelegate.cpp @@ -0,0 +1,173 @@ +#include "texturerowdelegate.h" + +#include "launcherformat.h" +#include "texturelistmodel.h" +#include "texturerecord.h" +#include "thememanager.h" +#include "tokens.h" + +#include +#include +#include +#include +#include + +namespace { + +QColor tc(const char* token) +{ + return ThemeManager::instance().theme().color(token); +} + +int themeSpace(const char* key, int fallback) +{ + const int s = ThemeManager::instance().theme().space(key); + return s > 0 ? s : fallback; +} + +// Fixed right-hand columns, widest first so they don't jitter as content +// changes. The name takes whatever is left. +constexpr int kModifiedWidth = 96; +constexpr int kOpenedWidth = 96; +constexpr int kResolutionWidth = 72; +constexpr int kNodesWidth = 64; + +} // namespace + +TextureRowDelegate::TextureRowDelegate(QObject* parent) : QStyledItemDelegate(parent) {} + +QSize TextureRowDelegate::sizeHint(const QStyleOptionViewItem&, const QModelIndex&) const +{ + const int pad = themeSpace("sm", 4); + return QSize(0, ThumbSize + pad * 2); +} + +void TextureRowDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const +{ + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + + const bool selected = option.state & QStyle::State_Selected; + const bool hovered = option.state & QStyle::State_MouseOver; + const bool missing = index.data(TextureListModel::MissingRole).toBool(); + const bool starred = index.data(TextureListModel::StarredRole).toBool(); + const bool isOpen = index.data(TextureListModel::IsOpenRole).toBool(); + const bool needsMigration = index.data(TextureListModel::NeedsMigrationRole).toBool(); + + const int pad = themeSpace("sm", 4); + const int radius = ThemeManager::instance().theme().radius("sm"); + + const QRect row = option.rect; + + if (hovered || selected) { + painter->setPen(Qt::NoPen); + painter->setBrush(selected ? tc(Tokens::LauncherCard) : tc(Tokens::LauncherCardHover)); + painter->drawRect(row); + } + + // Thumbnail. + QRect thumb(row.left() + pad * 2, row.top() + pad, ThumbSize, ThumbSize); + painter->setPen(Qt::NoPen); + painter->setBrush(tc(Tokens::LauncherThumbBg)); + painter->drawRoundedRect(thumb, radius, radius); + + const QPixmap preview = index.data(TextureListModel::ThumbnailRole).value(); + if (!preview.isNull()) { + painter->save(); + QPainterPath clip; + clip.addRoundedRect(thumb, radius, radius); + painter->setClipPath(clip); + const QPixmap scaled = preview.scaled(thumb.size(), Qt::KeepAspectRatioByExpanding, + Qt::SmoothTransformation); + painter->drawPixmap(thumb.center() - QPoint(scaled.width() / 2, scaled.height() / 2), + scaled); + painter->restore(); + } + + const QFont uiFont = ThemeManager::instance().theme().font("ui"); + const QFontMetrics metrics(uiFont); + painter->setFont(uiFont); + + // Right-hand columns are laid out from the right edge inwards, so the name + // column absorbs the window width rather than the numbers drifting. + int right = row.right() - pad * 2; + + auto column = [&](int width, const QString& text) { + const QRect cell(right - width, row.top(), width, row.height()); + painter->drawText(cell, Qt::AlignRight | Qt::AlignVCenter | Qt::TextSingleLine, + metrics.elidedText(text, Qt::ElideRight, width - pad)); + right -= width; + }; + + painter->setPen(tc(Tokens::TextSecondary)); + + const int nodes = index.data(TextureListModel::NodeCountRole).toInt(); + column(kNodesWidth, nodes > 0 ? QStringLiteral("%1").arg(nodes) : QString()); + column(kResolutionWidth, + launcherfmt::resolutionLabel(index.data(TextureListModel::WidthRole).toInt(), + index.data(TextureListModel::HeightRole).toInt())); + column(kOpenedWidth, + launcherfmt::relativeTime(index.data(TextureListModel::OpenedRole).toLongLong())); + column(kModifiedWidth, + missing ? QObject::tr("missing") + : launcherfmt::relativeTime( + index.data(TextureListModel::ModifiedRole).toLongLong())); + + // Markers sit between the thumbnail and the name so the name column starts + // at a predictable place whether or not a row carries any. + int left = thumb.right() + pad * 2; + const int markSize = metrics.height() / 2; + + if (isOpen) { + painter->setPen(Qt::NoPen); + painter->setBrush(tc(Tokens::LauncherOpenDot)); + painter->drawEllipse(QRect(left, row.center().y() - markSize / 2, markSize, markSize)); + left += markSize + pad; + } + if (needsMigration) { + const QString version = index.data(TextureListModel::LibVersionRole).toString(); + const QSize badge = launcherfmt::versionBadgeSize(version); + if (!badge.isEmpty()) { + launcherfmt::paintVersionBadge( + painter, + QRect(QPoint(left, row.center().y() - badge.height() / 2), badge), version); + left += badge.width() + pad; + } + } + if (starred) { + launcherfmt::paintStar( + painter, + QRectF(left, row.center().y() - markSize / 2.0, markSize, markSize), + tc(Tokens::LauncherStar)); + left += markSize + pad; + } + + QFont nameFont = uiFont; + nameFont.setStrikeOut(missing); + painter->setFont(nameFont); + painter->setPen(missing ? tc(Tokens::TextDisabled) : tc(Tokens::TextPrimary)); + + const QRect nameRect(left, row.top(), qMax(0, right - left - pad), row.height()); + painter->drawText(nameRect, Qt::AlignLeft | Qt::AlignVCenter | Qt::TextSingleLine, + QFontMetrics(nameFont).elidedText( + index.data(TextureListModel::NameRole).toString(), Qt::ElideMiddle, + nameRect.width())); + + // Hairline separator, drawn last and only between rows. + painter->setPen(tc(Tokens::LauncherCardBorder)); + painter->drawLine(row.left(), row.bottom(), row.right(), row.bottom()); + + painter->restore(); +} + +bool TextureRowDelegate::helpEvent(QHelpEvent* event, QAbstractItemView* view, + const QStyleOptionViewItem& option, const QModelIndex& index) +{ + if (!event || !view || !index.isValid()) + return QStyledItemDelegate::helpEvent(event, view, option, index); + + QToolTip::showText(event->globalPos(), + launcherfmt::tooltipFor(index, currentVersionLabel), view); + return true; +} diff --git a/src/texturelab/launcher/texturerowdelegate.h b/src/texturelab/launcher/texturerowdelegate.h new file mode 100644 index 00000000..76e0e85b --- /dev/null +++ b/src/texturelab/launcher/texturerowdelegate.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +// Paints one texture as a table-like row: small thumbnail, name, then aligned +// columns for modified / opened / resolution / nodes. +// +// Shares the model with TextureCardDelegate and reads exactly the same roles — +// which is the whole reason the model exposes typed values and formats nothing. +// Past a few hundred textures a list beats a grid for finding a known name, and +// Resolve ships both for the same reason. +class TextureRowDelegate : public QStyledItemDelegate { + Q_OBJECT + +public: + explicit TextureRowDelegate(QObject* parent = nullptr); + + void setCurrentVersionLabel(const QString& label) { currentVersionLabel = label; } + + static constexpr int ThumbSize = 40; + + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override; + + bool helpEvent(QHelpEvent* event, QAbstractItemView* view, + const QStyleOptionViewItem& option, const QModelIndex& index) override; + +private: + QString currentVersionLabel; +}; diff --git a/src/texturelab/main.cpp b/src/texturelab/main.cpp index 33cd06e1..d8a68e27 100644 --- a/src/texturelab/main.cpp +++ b/src/texturelab/main.cpp @@ -1,3 +1,4 @@ +#include "catalogservice.h" #include "mainwindow.h" #include "telemetry.h" #include "thememanager.h" @@ -136,6 +137,12 @@ int main(int argc, char* argv[]) // Now applicationDirPath() is valid — init Sentry Telemetry::init(crashReportingEnabled); + // Launcher index + thumbnail cache. Failure is not fatal: the app runs + // normally without them, it just has nothing to show in the launcher. + // Seeds from the legacy recent-files list on first run (LAUNCHER_PRD.md §1.1). + if (CatalogService::instance().init()) + CatalogService::instance().reconcileAsync(); + // Crash-test hook for verifying Sentry symbolication end-to-end. // Must run after Telemetry::init so the crash handler is armed. for (int i = 1; i < argc; ++i) { @@ -155,10 +162,32 @@ int main(int argc, char* argv[]) qInstallMessageHandler(qtMessageHandler); MainWindow w; - w.show(); - w.showMaximized(); + + // The launcher comes up first and MainWindow stays hidden until something + // is opened or created. Constructing it here is unavoidable — it owns the + // renderer and the dock layout — but not showing it keeps the empty editor + // off screen behind the launcher. `--no-launcher` skips straight to the + // editor, which is what you want when iterating on the editor itself. + bool useLauncher = true; + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--no-launcher") == 0) + useLauncher = false; + } + + if (useLauncher) { + w.showLauncher(); + } + else { + w.show(); + w.showMaximized(); + } int ret = a.exec(); + + // Close the catalog databases here, while Qt's SQL layer is still alive. + // Leaving it to static destruction crashes on exit (see instance()). + CatalogService::instance().shutdown(); + Telemetry::close(); return ret; } diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index c92cc254..c0693162 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -33,7 +33,9 @@ #include "DockAreaWidget.h" #include "DockSplitter.h" +#include "catalogservice.h" #include "exporter.h" +#include "launcher/launcherwindow.h" #include "telemetry.h" #include "thememanager.h" #include "tokens.h" @@ -120,6 +122,15 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) versionLabel->setToolTip("Application version and build hash"); statusBar()->addWidget(versionLabel); + // Home: reopens the launcher over the editor (LAUNCHER_PRD.md §7). + auto* homeButton = new QToolButton(); + homeButton->setObjectName("StatusHomeButton"); + homeButton->setText("⌂"); + homeButton->setToolTip("Show all textures"); + homeButton->setCursor(Qt::PointingHandCursor); + connect(homeButton, &QToolButton::clicked, this, &MainWindow::showLauncher); + statusBar()->addWidget(homeButton); + statusLayout->addWidget(statusLabel, 0, Qt::AlignVCenter); statusLayout->addWidget(progressBar, 0, Qt::AlignVCenter); statusBar()->addWidget(statusWidget, 1); @@ -428,6 +439,13 @@ void MainWindow::setProject(TextureProjectPtr project) progressBar->setValue(total == 0 ? 1 : clean); if (total == 0 || clean == total) { statusLabel->setText("Ready"); + + // The graph is fully evaluated: this is the first moment a + // freshly opened document is worth photographing. + if (thumbnailCapturePending) { + thumbnailCapturePending = false; + captureLauncherThumbnail(int(catalog::ThumbSource::Open)); + } } else { statusLabel->setText( @@ -718,6 +736,88 @@ ads::CDockAreaWidget* MainWindow::addDock(const QString& title, return newAreaWidget; } +void MainWindow::captureLauncherThumbnail(int source) +{ + CatalogService& catalog = CatalogService::instance(); + if (!catalog.isReady() || !project || project->filePath.isEmpty()) + return; + + auto* viewer = view3DWidget ? view3DWidget->viewer : nullptr; + if (!viewer || !viewer->isValid()) + return; + + // The one moment the whole graph is evaluated and resident on the GPU, so + // we just take the picture — no headless evaluator, no second GL context + // (LAUNCHER_PRD.md §4). Whatever the user framed is what the card shows. + const QImage frame = viewer->grabFramebuffer(); + if (frame.isNull()) + return; + + const catalog::TextureRecord rec = catalog.index().byPath(project->filePath); + if (!rec.isValid()) + return; + + catalog.captureThumbnail(rec.id, frame, static_cast(source)); +} + +void MainWindow::showLauncher() +{ + if (!launcher) { + launcher = new LauncherWindow(); + + connect(launcher, &LauncherWindow::newTextureRequested, this, [this]() { + launcher->hide(); + newProject(); + showMaximized(); + raise(); + activateWindow(); + }); + + // Routed through openProjectFromPath so the launcher inherits the + // dirty-document prompt and the library-version upgrade dialog rather + // than reimplementing either. + connect(launcher, &LauncherWindow::openPathRequested, this, + [this](const QString& path) { + const QString before = project ? project->filePath : QString(); + openProjectFromPath(path); + + // openProjectFromPath bails out silently if the user + // cancels the save prompt or the file won't read; in that + // case leave the launcher up rather than dropping them into + // an editor they didn't ask for. + if (project && project->filePath == path && project->filePath != before) { + launcher->hide(); + showMaximized(); + raise(); + activateWindow(); + } + }); + + connect(launcher, &LauncherWindow::openDialogRequested, this, [this]() { + openProject(); + if (project && !project->filePath.isEmpty()) { + launcher->hide(); + showMaximized(); + raise(); + activateWindow(); + } + }); + + connect(launcher, &LauncherWindow::closeRequested, this, [this]() { + launcher->hide(); + showMaximized(); + raise(); + activateWindow(); + }); + } + + launcher->setHasDocument(project && !project->filePath.isEmpty()); + launcher->setOpenPath(project ? project->filePath : QString()); + launcher->show(); + launcher->raise(); + launcher->activateWindow(); +} + void MainWindow::openProject() { if (!promptSaveIfDirty()) @@ -777,6 +877,15 @@ void MainWindow::openProjectFromPath(const QString& filePath) "open: " + fileInfo.baseName().toStdString()); setProject(project); addToRecentFiles(filePath); + + // The shared entry point for the Open dialog, the recent-files menu, and + // drag-and-drop, so one hook here covers all three (LAUNCHER_PRD.md §6.1). + CatalogService::instance().recordOpened(project, filePath); + + // Can't grab yet: opening kicks off an asynchronous render and the viewport + // is still showing the previous document (or nothing). Captured when + // renderProgress reports every node clean. + thumbnailCapturePending = true; } void MainWindow::dragEnterEvent(QDragEnterEvent* event) @@ -881,6 +990,8 @@ void MainWindow::saveProject() file.close(); undoStack->setClean(); addToRecentFiles(project->filePath); + CatalogService::instance().recordSaved(project, project->filePath); + captureLauncherThumbnail(int(catalog::ThumbSource::Save)); } void MainWindow::saveProjectAs() @@ -908,6 +1019,8 @@ void MainWindow::saveProjectAs() undoStack->setClean(); setWindowTitle(project->name + " - TextureLab"); addToRecentFiles(project->filePath); + CatalogService::instance().recordSaved(project, project->filePath); + captureLauncherThumbnail(int(catalog::ThumbSource::Save)); } void MainWindow::showExportDialog() @@ -1091,8 +1204,26 @@ void MainWindow::updateRecentFilesMenu() { recentFilesMenu->clear(); - QSettings settings; - QStringList files = settings.value("recentFiles").toStringList(); + // Reads through to the catalog index rather than QSettings, so this menu + // and the launcher can't disagree about what you opened last. QSettings is + // still written by addToRecentFiles() as a fallback for the case where the + // index failed to open. + QStringList files; + CatalogService& catalog = CatalogService::instance(); + + if (catalog.isReady()) { + catalog::Query query; + query.filter = catalog::Filter::Recents; + query.sort = catalog::SortKey::Opened; + query.ascending = false; + query.limit = MaxRecentFiles; + + for (const catalog::TextureRecord& rec : catalog.index().list(query)) + files << rec.path; + } + else { + files = QSettings().value("recentFiles").toStringList(); + } for (const QString& filePath : files) { QFileInfo info(filePath); @@ -1107,8 +1238,16 @@ void MainWindow::updateRecentFilesMenu() recentFilesMenu->addAction("No recent files")->setEnabled(false); recentFilesMenu->addSeparator(); - recentFilesMenu->addAction("Clear Recent Files", - [this]() { QSettings().remove("recentFiles"); }); + recentFilesMenu->addAction("Clear Recent Files", [this]() { + QSettings().remove("recentFiles"); + + // Clearing the menu must not delete the user's stars, tags, or the + // textures themselves — only forget when they were last opened. The + // launcher keeps showing them under All. + CatalogService& catalog = CatalogService::instance(); + if (catalog.isReady()) + catalog.index().clearRecents(); + }); } void MainWindow::onCleanChanged(bool clean) diff --git a/src/texturelab/mainwindow.h b/src/texturelab/mainwindow.h index c58f6c5d..d9cdab56 100644 --- a/src/texturelab/mainwindow.h +++ b/src/texturelab/mainwindow.h @@ -21,6 +21,7 @@ class View2DWidget; class View3DWidget; class TextureRenderer; class ExportDialog; +class LauncherWindow; class TextureProject; typedef QSharedPointer TextureProjectPtr; @@ -35,6 +36,10 @@ class MainWindow : public QMainWindow { MainWindow(QWidget* parent = nullptr); ~MainWindow(); + // Shows the launcher over the editor. Called at startup (before this window + // is ever shown) and from the Home button in the status bar. + void showLauncher(); + protected: void setupToolbar(); void setupMenus(); @@ -57,6 +62,11 @@ class MainWindow : public QMainWindow { void directExport(); void handleExport(const QString& destination, const QString& pattern); + // Grabs the 3D viewport and stores it as this texture's launcher thumbnail. + // No-op when the viewport has never initialized its GL context, or when the + // texture isn't in the index. + void captureLauncherThumbnail(int source); + void passTextureChannelsToViewer3D(); void syncChannelLabelsToScene(); @@ -96,6 +106,15 @@ class MainWindow : public QMainWindow { View3DWidget* view3DWidget; ExportDialog* exportDialog; + // Created lazily on first show; owned by this window so it survives being + // hidden and reopened from the Home button. + LauncherWindow* launcher = nullptr; + + // Set when a document is opened, cleared once the graph has finished + // rendering and the thumbnail has been captured. Opening is asynchronous — + // the viewport shows nothing useful until the last node is clean. + bool thumbnailCapturePending = false; + TextureRenderer* renderer; QProgressBar* progressBar; diff --git a/src/theme/tokens.h b/src/theme/tokens.h index 08bd53b3..ddb87177 100644 --- a/src/theme/tokens.h +++ b/src/theme/tokens.h @@ -41,6 +41,17 @@ constexpr const char* FrameSelect = "frame.select"; constexpr const char* CommentFill = "comment.fill"; constexpr const char* CommentText = "comment.text"; +// --- launcher / project manager (surface B) --- +constexpr const char* LauncherCard = "launcher.card"; +constexpr const char* LauncherCardHover = "launcher.card.hover"; +constexpr const char* LauncherCardBorder = "launcher.card.border"; +constexpr const char* LauncherThumbBg = "launcher.thumb.bg"; +constexpr const char* LauncherStar = "launcher.star"; +constexpr const char* LauncherBadge = "launcher.badge"; +constexpr const char* LauncherPipOff = "launcher.pip.off"; +constexpr const char* LauncherPipOn = "launcher.pip.on"; +constexpr const char* LauncherOpenDot = "launcher.open.dot"; + // --- 2D viewport (surface B/D) --- constexpr const char* View2dBg = "view2d.bg"; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 00000000..0f44b12a --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.10) + +# QtTest rather than gtest or catch2: Qt is already a hard dependency, so this +# adds no third-party code to vendor, update, or explain. + +set(CMAKE_AUTOMOC ON) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core) +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Test) + +# Each test file becomes its own binary, so one crashing suite can't take the +# others' results with it. +function(texturelab_add_test name) + add_executable(${name} ${name}.cpp) + target_link_libraries(${name} PRIVATE + catalog + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Test + ) + add_test(NAME ${name} COMMAND ${name}) + set_tests_properties(${name} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +endfunction() + +texturelab_add_test(tst_database) +texturelab_add_test(tst_catalogindex) +texturelab_add_test(tst_thumbnailcache) +# QImage/QPixmap round-trip needs the GUI module. +target_link_libraries(tst_thumbnailcache PRIVATE Qt${QT_VERSION_MAJOR}::Gui) + +# The launcher's model is deliberately free of app dependencies — Qt plus the +# catalog and nothing else — so its .cpp can be compiled straight into a test +# without dragging in the node graph or an OpenGL context. +texturelab_add_test(tst_texturelistmodel) +target_sources(tst_texturelistmodel PRIVATE + ${CMAKE_SOURCE_DIR}/src/texturelab/launcher/texturelistmodel.cpp) +target_include_directories(tst_texturelistmodel PRIVATE + ${CMAKE_SOURCE_DIR}/src/texturelab/launcher) +target_link_libraries(tst_texturelistmodel PRIVATE Qt${QT_VERSION_MAJOR}::Gui) diff --git a/tests/tst_catalogindex.cpp b/tests/tst_catalogindex.cpp new file mode 100644 index 00000000..d033bdce --- /dev/null +++ b/tests/tst_catalogindex.cpp @@ -0,0 +1,501 @@ +#include "catalogindex.h" + +#include +#include +#include + +using namespace catalog; + +namespace { + +constexpr qint64 kT0 = 1'700'000'000'000LL; // arbitrary fixed "now", in ms + +TextureRecord makeRecord(const QString& path) +{ + TextureRecord rec; + rec.path = path; + rec.name = QFileInfo(path).completeBaseName(); + rec.fileSize = 4096; + rec.fileMtime = kT0; + rec.width = 2048; + rec.height = 2048; + rec.nodeCount = 12; + rec.libVersion = QStringLiteral("v3"); + rec.channels = ChannelAlbedo | ChannelNormal | ChannelRoughness; + return rec; +} + +} // namespace + +class TestCatalogIndex : public QObject { + Q_OBJECT + +private slots: + void init(); + void cleanup(); + + void createsSchemaOnFirstOpen(); + void reopenPreservesRows(); + void refusesToWriteWhenSchemaIsNewer(); + + void recordOpenedInsertsAndStamps(); + void recordOpenedTwiceDoesNotDuplicate(); + void createdAtSurvivesLaterWrites(); + void recordSavedStampsSavedNotOpened(); + void recordingClearsMissingFlag(); + void refusesRecordWithoutPath(); + + void movedFileBecomesASeparateRow(); + + void relocateRepointsAMissingRow(); + void relocateMergesWhenTargetIsAlreadyIndexed(); + void relocateRejectsUnknownRows(); + + void markMissingStampsOnlyOnce(); + void markPresentUpdatesFileStateAndClearsMissing(); + + void listFiltersRecentsExcludingMissing(); + void listFiltersStarredIncludingMissing(); + void listSortsAndPages(); + void searchMatchesNameAndTagsCaseInsensitively(); + void searchTreatsWildcardsLiterally(); + + void tagsRoundTripAndCascadeOnDelete(); + void removeAllMissingOnlyRemovesMissing(); + +private: + QString indexPath() const { return dir->filePath(QStringLiteral("index.db")); } + + QScopedPointer dir; + QScopedPointer index; +}; + +void TestCatalogIndex::init() +{ + dir.reset(new QTemporaryDir); + QVERIFY(dir->isValid()); + index.reset(new CatalogIndex); + QVERIFY(index->open(indexPath())); +} + +void TestCatalogIndex::cleanup() +{ + index.reset(); + dir.reset(); +} + +void TestCatalogIndex::createsSchemaOnFirstOpen() +{ + QVERIFY(index->isOpen()); + QVERIFY(!index->isReadOnly()); + QCOMPARE(index->schemaVersion(), CatalogIndex::SchemaVersion); + QCOMPARE(index->count(Query()), 0); +} + +void TestCatalogIndex::reopenPreservesRows() +{ + TextureRecord rec = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + + index->close(); + QVERIFY(index->open(indexPath())); + + QCOMPARE(index->count(Query()), 1); + const TextureRecord loaded = index->byPath(QStringLiteral("/tex/brick.texture")); + QVERIFY(loaded.isValid()); + QCOMPARE(loaded.name, QStringLiteral("brick")); + QCOMPARE(loaded.channels, int(ChannelAlbedo | ChannelNormal | ChannelRoughness)); + QCOMPARE(loaded.libVersion, QStringLiteral("v3")); +} + +void TestCatalogIndex::refusesToWriteWhenSchemaIsNewer() +{ + // index.db cannot be regenerated by rescanning, so a file from a newer + // build must be left strictly alone rather than migrated on a guess. + TextureRecord rec = makeRecord(QStringLiteral("/tex/a.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + index->close(); + + { + Database raw; + QVERIFY(raw.open(indexPath())); + QVERIFY(raw.exec(QStringLiteral("UPDATE meta SET v = '999' WHERE k = 'schema_version'"))); + } + + QTest::ignoreMessage(QtWarningMsg, + QRegularExpression(QStringLiteral("newer than this build"))); + QVERIFY(index->open(indexPath())); + QVERIFY(index->isReadOnly()); + + // Reads still work — the launcher should show what it can. + QCOMPARE(index->count(Query()), 1); + + TextureRecord blocked = makeRecord(QStringLiteral("/tex/b.texture")); + QVERIFY(!index->recordOpened(blocked, kT0)); + QVERIFY(!index->setStarred(1, true)); + QVERIFY(!index->remove(1)); + QCOMPARE(index->count(Query()), 1); +} + +void TestCatalogIndex::recordOpenedInsertsAndStamps() +{ + TextureRecord rec = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + + QVERIFY(rec.isValid()); + QCOMPARE(rec.lastOpened, kT0); + QCOMPARE(rec.lastSaved, 0LL); + QCOMPARE(rec.createdAt, kT0); + QVERIFY(!rec.isMissing()); + QCOMPARE(index->count(Query()), 1); +} + +void TestCatalogIndex::recordOpenedTwiceDoesNotDuplicate() +{ + TextureRecord first = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordOpened(first, kT0)); + + TextureRecord second = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordOpened(second, kT0 + 5000)); + + QCOMPARE(index->count(Query()), 1); + QCOMPARE(second.id, first.id); + QCOMPARE(second.lastOpened, kT0 + 5000); +} + +void TestCatalogIndex::createdAtSurvivesLaterWrites() +{ + TextureRecord rec = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + + TextureRecord again = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordSaved(again, kT0 + 60'000)); + + QCOMPARE(again.createdAt, kT0); + QCOMPARE(again.lastOpened, kT0); // preserved, not clobbered + QCOMPARE(again.lastSaved, kT0 + 60'000); +} + +void TestCatalogIndex::recordSavedStampsSavedNotOpened() +{ + TextureRecord rec = makeRecord(QStringLiteral("/tex/new.texture")); + QVERIFY(index->recordSaved(rec, kT0)); + + QCOMPARE(rec.lastSaved, kT0); + QCOMPARE(rec.lastOpened, 0LL); + + // A never-opened row must not appear under Recents. + Query recents; + recents.filter = Filter::Recents; + QCOMPARE(index->count(recents), 0); +} + +void TestCatalogIndex::recordingClearsMissingFlag() +{ + TextureRecord rec = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + QVERIFY(index->markMissing(rec.id, kT0 + 1000)); + QVERIFY(index->byId(rec.id).isMissing()); + + TextureRecord again = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordOpened(again, kT0 + 2000)); + QVERIFY(!index->byId(rec.id).isMissing()); +} + +void TestCatalogIndex::refusesRecordWithoutPath() +{ + TextureRecord rec; + QVERIFY(!index->recordOpened(rec, kT0)); + QCOMPARE(index->count(Query()), 0); +} + +void TestCatalogIndex::movedFileBecomesASeparateRow() +{ + // Paths are identity. Recognizing that a file moved would need a content + // hash or an mtime heuristic, and neither was worth its keep — so the old + // row simply stays behind, flagged missing, and the user removes it. The + // cost is losing stars and recency on a moved file; nothing breaks. + TextureRecord original = makeRecord(QStringLiteral("/old/brick.texture")); + QVERIFY(index->recordOpened(original, kT0)); + QVERIFY(index->setStarred(original.id, true)); + QVERIFY(index->addTag(original.id, QStringLiteral("stone"))); + QVERIFY(index->markMissing(original.id, kT0 + 1000)); + + TextureRecord moved = makeRecord(QStringLiteral("/new/brick.texture")); + QVERIFY(index->recordOpened(moved, kT0 + 2000)); + + QCOMPARE(index->count(Query()), 2); + QVERIFY(moved.id != original.id); + + // The old row is untouched and still dimmed, holding its own stars. + const TextureRecord stale = index->byId(original.id); + QVERIFY(stale.isValid()); + QVERIFY(stale.isMissing()); + QVERIFY(stale.starred); + + // The new one starts clean. + QVERIFY(!moved.starred); + QVERIFY(index->tags(moved.id).isEmpty()); + + // And "Remove from Launcher" is the way out. + QVERIFY(index->remove(original.id)); + QCOMPARE(index->count(Query()), 1); +} + +void TestCatalogIndex::relocateRepointsAMissingRow() +{ + // The user-driven counterpart to not detecting moves: the launcher can't + // guess where a file went, but Locate… lets the user say, and the row keeps + // everything it had. + TextureRecord rec = makeRecord(QStringLiteral("/old/brick.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + QVERIFY(index->setStarred(rec.id, true)); + QVERIFY(index->addTag(rec.id, QStringLiteral("stone"))); + QVERIFY(index->markMissing(rec.id, kT0 + 1000)); + + const qint64 survivor = + index->relocate(rec.id, QStringLiteral("/new/brick.texture"), 8192, kT0 + 5000); + QCOMPARE(survivor, rec.id); + + const TextureRecord moved = index->byId(rec.id); + QCOMPARE(moved.path, QStringLiteral("/new/brick.texture")); + QCOMPARE(moved.name, QStringLiteral("brick")); + QCOMPARE(moved.fileSize, 8192LL); + QCOMPARE(moved.fileMtime, kT0 + 5000); + QVERIFY(!moved.isMissing()); + QVERIFY(moved.starred); + QCOMPARE(index->tags(moved.id), QStringList({QStringLiteral("stone")})); + QCOMPARE(index->count(Query()), 1); +} + +void TestCatalogIndex::relocateMergesWhenTargetIsAlreadyIndexed() +{ + // path is UNIQUE, so relocating onto a path that's already indexed has to + // merge rather than fail — otherwise Locate… dead-ends exactly when the + // user already opened the file at its new home. + TextureRecord stale = makeRecord(QStringLiteral("/old/brick.texture")); + QVERIFY(index->recordOpened(stale, kT0)); + QVERIFY(index->setStarred(stale.id, true)); + QVERIFY(index->addTag(stale.id, QStringLiteral("stone"))); + QVERIFY(index->markMissing(stale.id, kT0 + 1000)); + + TextureRecord current = makeRecord(QStringLiteral("/new/brick.texture")); + QVERIFY(index->recordOpened(current, kT0 + 2000)); + QVERIFY(!current.starred); + + const qint64 survivor = + index->relocate(stale.id, QStringLiteral("/new/brick.texture"), 4096, kT0 + 2000); + + QCOMPARE(survivor, current.id); + QVERIFY(!index->byId(stale.id).isValid()); + QCOMPARE(index->count(Query()), 1); + + const TextureRecord merged = index->byId(current.id); + QVERIFY(merged.starred); // carried across + QCOMPARE(merged.createdAt, kT0); // earliest of the two + QCOMPARE(index->tags(merged.id), QStringList({QStringLiteral("stone")})); +} + +void TestCatalogIndex::relocateRejectsUnknownRows() +{ + QCOMPARE(index->relocate(999, QStringLiteral("/x.texture"), 1, 1), -1LL); + QCOMPARE(index->relocate(1, QString(), 1, 1), -1LL); +} + +void TestCatalogIndex::markMissingStampsOnlyOnce() +{ + TextureRecord rec = makeRecord(QStringLiteral("/tex/gone.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + + QVERIFY(index->markMissing(rec.id, kT0 + 1000)); + QVERIFY(index->markMissing(rec.id, kT0 + 9999)); + + // The card wants to say how long it's been gone, so the first sighting + // wins rather than being reset on every launcher open. + QCOMPARE(index->byId(rec.id).missingSince, kT0 + 1000); +} + +void TestCatalogIndex::markPresentUpdatesFileStateAndClearsMissing() +{ + // What reconciliation does when a file turns out to still be there: record + // the current size/mtime and un-dim the card. Comparing those two values + // against the stored row is the system's only change detection — a caller + // that sees them differ drops the cached thumbnail before calling this. + TextureRecord rec = makeRecord(QStringLiteral("/tex/edited.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + QVERIFY(index->markMissing(rec.id, kT0 + 1000)); + + QVERIFY(index->markPresent(rec.id, 8192, kT0 + 5000)); + + const TextureRecord updated = index->byId(rec.id); + QVERIFY(!updated.isMissing()); + QCOMPARE(updated.fileSize, 8192LL); + QCOMPARE(updated.fileMtime, kT0 + 5000); +} + +void TestCatalogIndex::listFiltersRecentsExcludingMissing() +{ + TextureRecord opened = makeRecord(QStringLiteral("/tex/opened.texture")); + QVERIFY(index->recordOpened(opened, kT0)); + + TextureRecord savedOnly = makeRecord(QStringLiteral("/tex/saved.texture")); + QVERIFY(index->recordSaved(savedOnly, kT0)); + + TextureRecord missing = makeRecord(QStringLiteral("/tex/missing.texture")); + QVERIFY(index->recordOpened(missing, kT0)); + QVERIFY(index->markMissing(missing.id, kT0 + 1000)); + + Query recents; + recents.filter = Filter::Recents; + const QVector rows = index->list(recents); + + QCOMPARE(rows.size(), 1); + QCOMPARE(rows.first().path, QStringLiteral("/tex/opened.texture")); + + // All still shows everything, missing included — those cards render dimmed. + QCOMPARE(index->count(Query()), 3); +} + +void TestCatalogIndex::listFiltersStarredIncludingMissing() +{ + TextureRecord starred = makeRecord(QStringLiteral("/tex/star.texture")); + QVERIFY(index->recordOpened(starred, kT0)); + QVERIFY(index->setStarred(starred.id, true)); + QVERIFY(index->markMissing(starred.id, kT0 + 1000)); + + TextureRecord plain = makeRecord(QStringLiteral("/tex/plain.texture")); + QVERIFY(index->recordOpened(plain, kT0)); + + Query query; + query.filter = Filter::Starred; + const QVector rows = index->list(query); + + // An unmounted drive must not hide the things the user explicitly marked. + QCOMPARE(rows.size(), 1); + QCOMPARE(rows.first().path, QStringLiteral("/tex/star.texture")); + QVERIFY(rows.first().isMissing()); +} + +void TestCatalogIndex::listSortsAndPages() +{ + const QStringList names = {QStringLiteral("charlie"), QStringLiteral("alpha"), + QStringLiteral("bravo"), QStringLiteral("delta")}; + for (int i = 0; i < names.size(); ++i) { + TextureRecord rec = makeRecord(QStringLiteral("/tex/%1.texture").arg(names[i])); + rec.fileMtime = kT0 + i * 1000; + rec.fileSize = 1000 * (i + 1); + QVERIFY(index->recordOpened(rec, kT0 + i * 1000)); + } + + Query byName; + byName.sort = SortKey::Name; + byName.ascending = true; + QVector rows = index->list(byName); + QCOMPARE(rows.size(), 4); + QCOMPARE(rows[0].name, QStringLiteral("alpha")); + QCOMPARE(rows[3].name, QStringLiteral("delta")); + + Query newestFirst; + newestFirst.sort = SortKey::Modified; + newestFirst.ascending = false; + rows = index->list(newestFirst); + QCOMPARE(rows.first().name, QStringLiteral("delta")); + + Query biggestFirst; + biggestFirst.sort = SortKey::Size; + biggestFirst.ascending = false; + rows = index->list(biggestFirst); + QCOMPARE(rows.first().fileSize, 4000LL); + + // Paging: the grid asks for a window, not the whole table. + Query page; + page.sort = SortKey::Name; + page.ascending = true; + page.limit = 2; + page.offset = 1; + rows = index->list(page); + QCOMPARE(rows.size(), 2); + QCOMPARE(rows[0].name, QStringLiteral("bravo")); + QCOMPARE(rows[1].name, QStringLiteral("charlie")); + + // count() ignores paging — it's the total the scrollbar needs. + QCOMPARE(index->count(page), 4); +} + +void TestCatalogIndex::searchMatchesNameAndTagsCaseInsensitively() +{ + TextureRecord brick = makeRecord(QStringLiteral("/tex/BrickWall.texture")); + QVERIFY(index->recordOpened(brick, kT0)); + + TextureRecord metal = makeRecord(QStringLiteral("/tex/rust.texture")); + QVERIFY(index->recordOpened(metal, kT0)); + QVERIFY(index->addTag(metal.id, QStringLiteral("Brickish"))); + + TextureRecord other = makeRecord(QStringLiteral("/tex/cloth.texture")); + QVERIFY(index->recordOpened(other, kT0)); + + Query query; + query.search = QStringLiteral("brick"); + const QVector rows = index->list(query); + + QCOMPARE(rows.size(), 2); + QCOMPARE(index->count(query), 2); +} + +void TestCatalogIndex::searchTreatsWildcardsLiterally() +{ + TextureRecord percent = makeRecord(QStringLiteral("/tex/50%25 grey.texture")); + percent.name = QStringLiteral("50% grey"); + QVERIFY(index->recordOpened(percent, kT0)); + + TextureRecord other = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordOpened(other, kT0)); + + // Unescaped, "%" would match every row. + Query query; + query.search = QStringLiteral("%"); + QCOMPARE(index->count(query), 1); + + // Same for the single-character wildcard. + query.search = QStringLiteral("_"); + QCOMPARE(index->count(query), 0); +} + +void TestCatalogIndex::tagsRoundTripAndCascadeOnDelete() +{ + TextureRecord rec = makeRecord(QStringLiteral("/tex/tagged.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + + QVERIFY(index->addTag(rec.id, QStringLiteral("stone"))); + QVERIFY(index->addTag(rec.id, QStringLiteral("outdoor"))); + QVERIFY(index->addTag(rec.id, QStringLiteral("stone"))); // idempotent + + QCOMPARE(index->tags(rec.id), + QStringList({QStringLiteral("outdoor"), QStringLiteral("stone")})); + + QVERIFY(index->removeTag(rec.id, QStringLiteral("outdoor"))); + QCOMPARE(index->tags(rec.id), QStringList({QStringLiteral("stone")})); + + // The tag table is WITHOUT ROWID with an ON DELETE CASCADE foreign key; + // this proves both are actually in force. + QVERIFY(index->remove(rec.id)); + QCOMPARE(index->tags(rec.id), QStringList()); + QCOMPARE(index->database().scalar(QStringLiteral("SELECT count(*) FROM tag")), 0LL); +} + +void TestCatalogIndex::removeAllMissingOnlyRemovesMissing() +{ + TextureRecord present = makeRecord(QStringLiteral("/tex/here.texture")); + QVERIFY(index->recordOpened(present, kT0)); + + TextureRecord gone = makeRecord(QStringLiteral("/tex/gone.texture")); + QVERIFY(index->recordOpened(gone, kT0)); + QVERIFY(index->markMissing(gone.id, kT0 + 1000)); + + QCOMPARE(index->removeAllMissing(), 1); + QCOMPARE(index->count(Query()), 1); + QVERIFY(index->byPath(QStringLiteral("/tex/here.texture")).isValid()); +} + +QTEST_GUILESS_MAIN(TestCatalogIndex) +#include "tst_catalogindex.moc" diff --git a/tests/tst_database.cpp b/tests/tst_database.cpp new file mode 100644 index 00000000..30ab3584 --- /dev/null +++ b/tests/tst_database.cpp @@ -0,0 +1,293 @@ +#include "database.h" + +#include +#include +#include + +using namespace catalog; + +class TestDatabase : public QObject { + Q_OBJECT + +private slots: + void init(); + + void opensFileAndReportsOpen(); + void appliesPageSizeBeforeAnyDdl(); + void appliesIncrementalAutoVacuum(); + void enablesWalMode(); + void enablesForeignKeys(); + void memoryDatabaseSkipsFilePragmas(); + + void transactionCommitPersists(); + void transactionRollsBackWhenScopeExits(); + void transactionRollsBackOnExplicitCall(); + void nestedTransactionIsInertAndDoesNotCommitOuter(); + + void execBatchIsAtomic(); + void scalarReturnsFallbackOnFailure(); + void readOnlyConnectionRejectsWrites(); + + void separateConnectionsCanReadConcurrently(); + +private: + QString dbFile(const QString& name) const { return dir.filePath(name); } + + QTemporaryDir dir; +}; + +void TestDatabase::init() +{ + QVERIFY(dir.isValid()); +} + +void TestDatabase::opensFileAndReportsOpen() +{ + Database db; + QVERIFY(db.open(dbFile(QStringLiteral("open.db")))); + QVERIFY(db.isOpen()); + + db.close(); + QVERIFY(!db.isOpen()); +} + +void TestDatabase::appliesPageSizeBeforeAnyDdl() +{ + // The ordering this asserts is the whole reason Database applies PRAGMAs + // itself: page_size only takes on an empty file. If a future refactor + // creates a table first, this drops back to the 4096 default and the + // setting is silently lost. + const QString path = dbFile(QStringLiteral("pagesize.db")); + + Database::Options options; + options.pageSize = 8192; + + Database db; + QVERIFY(db.open(path, options)); + QVERIFY(db.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); + + QCOMPARE(db.scalar(QStringLiteral("PRAGMA page_size")), 8192LL); + + // And it survives a reopen, i.e. it was written into the file header + // rather than just held in the connection. + db.close(); + Database reopened; + QVERIFY(reopened.open(path)); + QCOMPARE(reopened.scalar(QStringLiteral("PRAGMA page_size")), 8192LL); +} + +void TestDatabase::appliesIncrementalAutoVacuum() +{ + const QString path = dbFile(QStringLiteral("autovacuum.db")); + + Database::Options options; + options.incrementalAutoVacuum = true; + + Database db; + QVERIFY(db.open(path, options)); + QVERIFY(db.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); + + // 0 = NONE, 1 = FULL, 2 = INCREMENTAL. + QCOMPARE(db.scalar(QStringLiteral("PRAGMA auto_vacuum")), 2LL); + + db.close(); + Database reopened; + QVERIFY(reopened.open(path)); + QCOMPARE(reopened.scalar(QStringLiteral("PRAGMA auto_vacuum")), 2LL); +} + +void TestDatabase::enablesWalMode() +{ + Database db; + QVERIFY(db.open(dbFile(QStringLiteral("wal.db")))); + + QSqlQuery query = db.prepare(QStringLiteral("PRAGMA journal_mode")); + QVERIFY(query.exec()); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toString().toLower(), QStringLiteral("wal")); +} + +void TestDatabase::enablesForeignKeys() +{ + Database db; + QVERIFY(db.open(dbFile(QStringLiteral("fk.db")))); + QCOMPARE(db.scalar(QStringLiteral("PRAGMA foreign_keys")), 1LL); + + QVERIFY(db.exec(QStringLiteral("CREATE TABLE parent (id INTEGER PRIMARY KEY)"))); + QVERIFY(db.exec(QStringLiteral( + "CREATE TABLE child (id INTEGER PRIMARY KEY, " + "parent_id INTEGER REFERENCES parent(id) ON DELETE CASCADE)"))); + QVERIFY(db.exec(QStringLiteral("INSERT INTO parent (id) VALUES (1)"))); + QVERIFY(db.exec(QStringLiteral("INSERT INTO child (id, parent_id) VALUES (1, 1)"))); + + // The cascade is what the tag table depends on for cleanup. + QVERIFY(db.exec(QStringLiteral("DELETE FROM parent WHERE id = 1"))); + QCOMPARE(db.scalar(QStringLiteral("SELECT count(*) FROM child")), 0LL); +} + +void TestDatabase::memoryDatabaseSkipsFilePragmas() +{ + // WAL is meaningless in memory and setting it fails; open() must not treat + // that as an error, because every test below uses :memory:. + Database db; + QVERIFY(db.open(QStringLiteral(":memory:"))); + QVERIFY(db.isOpen()); + QVERIFY(db.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); +} + +void TestDatabase::transactionCommitPersists() +{ + Database db; + QVERIFY(db.open(QStringLiteral(":memory:"))); + QVERIFY(db.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); + + { + Transaction tx(db); + QVERIFY(tx.isActive()); + QVERIFY(db.exec(QStringLiteral("INSERT INTO t (a) VALUES (1)"))); + QVERIFY(tx.commit()); + } + + QCOMPARE(db.scalar(QStringLiteral("SELECT count(*) FROM t")), 1LL); +} + +void TestDatabase::transactionRollsBackWhenScopeExits() +{ + // The property that matters: an early return anywhere inside a write batch + // leaves nothing behind. Nobody has to remember to roll back. + Database db; + QVERIFY(db.open(QStringLiteral(":memory:"))); + QVERIFY(db.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); + + { + Transaction tx(db); + QVERIFY(tx.isActive()); + QVERIFY(db.exec(QStringLiteral("INSERT INTO t (a) VALUES (1)"))); + // No commit — destructor rolls back. + } + + QCOMPARE(db.scalar(QStringLiteral("SELECT count(*) FROM t")), 0LL); + QVERIFY(!db.inTransaction()); +} + +void TestDatabase::transactionRollsBackOnExplicitCall() +{ + Database db; + QVERIFY(db.open(QStringLiteral(":memory:"))); + QVERIFY(db.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); + + Transaction tx(db); + QVERIFY(db.exec(QStringLiteral("INSERT INTO t (a) VALUES (1)"))); + tx.rollback(); + + QVERIFY(!tx.isActive()); + QCOMPARE(db.scalar(QStringLiteral("SELECT count(*) FROM t")), 0LL); +} + +void TestDatabase::nestedTransactionIsInertAndDoesNotCommitOuter() +{ + Database db; + QVERIFY(db.open(QStringLiteral(":memory:"))); + QVERIFY(db.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); + + { + Transaction outer(db); + QVERIFY(outer.isActive()); + QVERIFY(db.exec(QStringLiteral("INSERT INTO t (a) VALUES (1)"))); + + { + QTest::ignoreMessage(QtWarningMsg, + "catalog: nested transaction requested; inner scope is inert"); + Transaction inner(db); + QVERIFY(!inner.isActive()); + QVERIFY(!inner.commit()); + } + + // The inner scope ending must not have committed or rolled back the + // outer one — the write is still pending and still reversible. + QVERIFY(db.inTransaction()); + QCOMPARE(db.scalar(QStringLiteral("SELECT count(*) FROM t")), 1LL); + } + + QCOMPARE(db.scalar(QStringLiteral("SELECT count(*) FROM t")), 0LL); +} + +void TestDatabase::execBatchIsAtomic() +{ + Database db; + QVERIFY(db.open(QStringLiteral(":memory:"))); + + QStringList statements; + statements << QStringLiteral("CREATE TABLE a (x INTEGER)") + << QStringLiteral("CREATE TABLE b (x INTEGER)") + << QStringLiteral("THIS IS NOT SQL"); + + QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("catalog: query failed"))); + QVERIFY(!db.execBatch(statements)); + + // Neither table should exist: the batch rolled back as a unit. + QCOMPARE(db.scalar(QStringLiteral("SELECT count(*) FROM sqlite_master WHERE type='table'")), + 0LL); +} + +void TestDatabase::scalarReturnsFallbackOnFailure() +{ + Database db; + QVERIFY(db.open(QStringLiteral(":memory:"))); + + QCOMPARE(db.scalar(QStringLiteral("SELECT x FROM nonexistent"), -7), -7LL); + QCOMPARE(db.scalar(QStringLiteral("SELECT NULL"), -7), -7LL); + QCOMPARE(db.scalar(QStringLiteral("SELECT 42"), -7), 42LL); +} + +void TestDatabase::readOnlyConnectionRejectsWrites() +{ + const QString path = dbFile(QStringLiteral("readonly.db")); + + { + Database writable; + QVERIFY(writable.open(path)); + QVERIFY(writable.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); + QVERIFY(writable.exec(QStringLiteral("INSERT INTO t (a) VALUES (1)"))); + } + + Database::Options options; + options.readOnly = true; + + Database readonly; + QVERIFY(readonly.open(path, options)); + QCOMPARE(readonly.scalar(QStringLiteral("SELECT count(*) FROM t")), 1LL); + + QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("catalog: query failed"))); + QVERIFY(!readonly.exec(QStringLiteral("INSERT INTO t (a) VALUES (2)"))); +} + +void TestDatabase::separateConnectionsCanReadConcurrently() +{ + // WAL's actual promise: a reader on one connection is not blocked by an + // open write transaction on another. The reconciliation pass depends on + // this, since it runs off-thread while the GUI reads. + const QString path = dbFile(QStringLiteral("concurrent.db")); + + Database writer; + QVERIFY(writer.open(path)); + QVERIFY(writer.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); + QVERIFY(writer.exec(QStringLiteral("INSERT INTO t (a) VALUES (1)"))); + + Database reader; + QVERIFY(reader.open(path)); + + Transaction tx(writer); + QVERIFY(tx.isActive()); + QVERIFY(writer.exec(QStringLiteral("INSERT INTO t (a) VALUES (2)"))); + + // Uncommitted write is invisible to the reader, and the read succeeds + // rather than blocking until the busy timeout expires. + QCOMPARE(reader.scalar(QStringLiteral("SELECT count(*) FROM t")), 1LL); + + QVERIFY(tx.commit()); + QCOMPARE(reader.scalar(QStringLiteral("SELECT count(*) FROM t")), 2LL); +} + +QTEST_GUILESS_MAIN(TestDatabase) +#include "tst_database.moc" diff --git a/tests/tst_texturelistmodel.cpp b/tests/tst_texturelistmodel.cpp new file mode 100644 index 00000000..9d2a7ddb --- /dev/null +++ b/tests/tst_texturelistmodel.cpp @@ -0,0 +1,290 @@ +#include "catalogindex.h" +#include "texturelistmodel.h" + +#include +#include +#include + +using namespace catalog; + +namespace { + +constexpr qint64 kT0 = 1'700'000'000'000LL; + +TextureRecord makeRecord(const QString& name, int ordinal) +{ + TextureRecord rec; + rec.path = QStringLiteral("/tex/%1.texture").arg(name); + rec.name = name; + rec.fileSize = 1000 * (ordinal + 1); + rec.fileMtime = kT0 + qint64(ordinal) * 1000; + rec.width = 2048; + rec.height = 2048; + rec.nodeCount = 12; + rec.libVersion = QStringLiteral("v3"); + rec.channels = ChannelAlbedo | ChannelNormal; + return rec; +} + +} // namespace + +class TestTextureListModel : public QObject { + Q_OBJECT + +private slots: + void init(); + void cleanup(); + + void emptyWithoutIndex(); + void passesModelTester(); + + void exposesRolesWithoutFormatting(); + void reportsTotalSeparatelyFromRowCount(); + void pagesInWithFetchMore(); + + void filterSwitchesRowSet(); + void sortChangesOrder(); + void searchNarrowsRows(); + + void openPathDrivesIsOpenRole(); + void migrationBadgeOnlyForKnownOlderVersions(); + + void refreshPicksUpExternalChanges(); + +private: + void seed(int count); + + QScopedPointer dir; + QScopedPointer index; + QScopedPointer model; +}; + +void TestTextureListModel::init() +{ + dir.reset(new QTemporaryDir); + QVERIFY(dir->isValid()); + + index.reset(new CatalogIndex); + QVERIFY(index->open(dir->filePath(QStringLiteral("index.db")))); + + model.reset(new TextureListModel); +} + +void TestTextureListModel::cleanup() +{ + model.reset(); + index.reset(); + dir.reset(); +} + +void TestTextureListModel::seed(int count) +{ + for (int i = 0; i < count; ++i) { + TextureRecord rec = makeRecord(QStringLiteral("tex%1").arg(i, 4, 10, QLatin1Char('0')), i); + QVERIFY(index->recordOpened(rec, kT0 + qint64(i) * 1000)); + } +} + +void TestTextureListModel::emptyWithoutIndex() +{ + // The launcher builds its model before the catalog is necessarily open; + // that must be an empty grid, not a crash. + QCOMPARE(model->rowCount(), 0); + QCOMPARE(model->totalCount(), 0); + QVERIFY(!model->data(model->index(0), TextureListModel::NameRole).isValid()); + QVERIFY(!model->canFetchMore(QModelIndex())); +} + +void TestTextureListModel::passesModelTester() +{ + // Catches the whole class of index/rowCount/parent contract violations that + // otherwise surface as a view crash on someone's machine. + seed(10); + QAbstractItemModelTester tester(model.data(), QAbstractItemModelTester::FailureReportingMode::Warning); + model->setIndex(index.data()); + model->setFilter(Filter::Recents); + model->setSort(SortKey::Name, true); + model->setSearchTerm(QStringLiteral("tex000")); + model->setSearchTerm(QString()); + model->refresh(); + QVERIFY(model->rowCount() > 0); +} + +void TestTextureListModel::exposesRolesWithoutFormatting() +{ + seed(1); + model->setIndex(index.data()); + QCOMPARE(model->rowCount(), 1); + + const QModelIndex idx = model->index(0); + + // Raw values, not display strings: no "2h ago", no "2K". Formatting belongs + // to the delegate, so the grid and a future list view can differ. + QCOMPARE(idx.data(TextureListModel::NameRole).toString(), QStringLiteral("tex0000")); + QCOMPARE(idx.data(TextureListModel::ModifiedRole).toLongLong(), kT0); + QCOMPARE(idx.data(TextureListModel::WidthRole).toInt(), 2048); + QCOMPARE(idx.data(TextureListModel::HeightRole).toInt(), 2048); + QCOMPARE(idx.data(TextureListModel::NodeCountRole).toInt(), 12); + QCOMPARE(idx.data(TextureListModel::ChannelsRole).toInt(), int(ChannelAlbedo | ChannelNormal)); + QCOMPARE(idx.data(TextureListModel::StarredRole).toBool(), false); + QCOMPARE(idx.data(TextureListModel::MissingRole).toBool(), false); + QCOMPARE(idx.data(Qt::ToolTipRole).toString(), QStringLiteral("/tex/tex0000.texture")); + + const TextureRecord rec = model->recordAt(idx); + QVERIFY(rec.isValid()); + QCOMPARE(model->indexForId(rec.id).row(), 0); +} + +void TestTextureListModel::reportsTotalSeparatelyFromRowCount() +{ + // The empty-state copy keys off totalCount(), which must describe the whole + // result set — not just the page that happens to be resident. + seed(TextureListModel::PageSize + 25); + model->setIndex(index.data()); + + QCOMPARE(model->rowCount(), TextureListModel::PageSize); + QCOMPARE(model->totalCount(), TextureListModel::PageSize + 25); +} + +void TestTextureListModel::pagesInWithFetchMore() +{ + const int extra = 25; + seed(TextureListModel::PageSize + extra); + model->setIndex(index.data()); + + QVERIFY(model->canFetchMore(QModelIndex())); + + QSignalSpy inserted(model.data(), &QAbstractItemModel::rowsInserted); + model->fetchMore(QModelIndex()); + + QCOMPARE(inserted.count(), 1); + QCOMPARE(model->rowCount(), TextureListModel::PageSize + extra); + QVERIFY(!model->canFetchMore(QModelIndex())); + + // Every row is distinct — an off-by-one in the offset would duplicate the + // page boundary, which looks like a rendering glitch rather than a bug. + QSet paths; + for (int row = 0; row < model->rowCount(); ++row) + paths.insert(model->index(row).data(TextureListModel::PathRole).toString()); + QCOMPARE(paths.size(), model->rowCount()); +} + +void TestTextureListModel::filterSwitchesRowSet() +{ + seed(3); + + // One saved but never opened, one starred. + TextureRecord savedOnly = makeRecord(QStringLiteral("savedonly"), 99); + QVERIFY(index->recordSaved(savedOnly, kT0)); + QVERIFY(index->setStarred(index->byPath(QStringLiteral("/tex/tex0000.texture")).id, true)); + + model->setIndex(index.data()); + QCOMPARE(model->totalCount(), 4); + + model->setFilter(Filter::Recents); + QCOMPARE(model->totalCount(), 3); // the save-only row is not a "recent" + + model->setFilter(Filter::Starred); + QCOMPARE(model->totalCount(), 1); + + model->setFilter(Filter::All); + QCOMPARE(model->totalCount(), 4); +} + +void TestTextureListModel::sortChangesOrder() +{ + seed(4); + model->setIndex(index.data()); + + model->setSort(SortKey::Name, true); + QCOMPARE(model->index(0).data(TextureListModel::NameRole).toString(), + QStringLiteral("tex0000")); + + model->setSort(SortKey::Name, false); + QCOMPARE(model->index(0).data(TextureListModel::NameRole).toString(), + QStringLiteral("tex0003")); + + model->setSort(SortKey::Modified, false); + QCOMPARE(model->index(0).data(TextureListModel::ModifiedRole).toLongLong(), kT0 + 3000); + + model->setSort(SortKey::Size, false); + QCOMPARE(model->index(0).data(TextureListModel::FileSizeRole).toLongLong(), 4000LL); +} + +void TestTextureListModel::searchNarrowsRows() +{ + seed(3); + model->setIndex(index.data()); + QCOMPARE(model->totalCount(), 3); + + model->setSearchTerm(QStringLiteral("tex0001")); + QCOMPARE(model->totalCount(), 1); + QCOMPARE(model->rowCount(), 1); + + model->setSearchTerm(QString()); + QCOMPARE(model->totalCount(), 3); +} + +void TestTextureListModel::openPathDrivesIsOpenRole() +{ + seed(2); + model->setIndex(index.data()); + model->setSort(SortKey::Name, true); + + QVERIFY(!model->index(0).data(TextureListModel::IsOpenRole).toBool()); + + QSignalSpy changed(model.data(), &QAbstractItemModel::dataChanged); + model->setOpenPath(QStringLiteral("/tex/tex0000.texture")); + + QCOMPARE(changed.count(), 1); + QVERIFY(model->index(0).data(TextureListModel::IsOpenRole).toBool()); + QVERIFY(!model->index(1).data(TextureListModel::IsOpenRole).toBool()); +} + +void TestTextureListModel::migrationBadgeOnlyForKnownOlderVersions() +{ + TextureRecord current = makeRecord(QStringLiteral("current"), 0); + current.libVersion = QStringLiteral("v3"); + QVERIFY(index->recordOpened(current, kT0)); + + TextureRecord old = makeRecord(QStringLiteral("old"), 1); + old.libVersion = QStringLiteral("v1"); + QVERIFY(index->recordOpened(old, kT0)); + + // Seeded from the recents list: we've never looked inside it. + TextureRecord unknown = makeRecord(QStringLiteral("unknown"), 2); + unknown.libVersion.clear(); + QVERIFY(index->recordOpened(unknown, kT0)); + + model->setIndex(index.data()); + model->setCurrentLibVersion(QStringLiteral("v3")); + model->setSort(SortKey::Name, true); + + auto badge = [this](int row) { + return model->index(row).data(TextureListModel::NeedsMigrationRole).toBool(); + }; + + QVERIFY(!badge(0)); // "current" + QVERIFY(badge(1)); // "old" + QVERIFY(!badge(2)); // "unknown" — absence of data is not evidence of age +} + +void TestTextureListModel::refreshPicksUpExternalChanges() +{ + seed(2); + model->setIndex(index.data()); + QCOMPARE(model->totalCount(), 2); + + TextureRecord added = makeRecord(QStringLiteral("added"), 5); + QVERIFY(index->recordOpened(added, kT0 + 99'000)); + + // The model doesn't watch the database; CatalogService::catalogChanged is + // what drives this in the app. + QCOMPARE(model->totalCount(), 2); + + model->refresh(); + QCOMPARE(model->totalCount(), 3); +} + +QTEST_MAIN(TestTextureListModel) +#include "tst_texturelistmodel.moc" diff --git a/tests/tst_thumbnailcache.cpp b/tests/tst_thumbnailcache.cpp new file mode 100644 index 00000000..4968edf4 --- /dev/null +++ b/tests/tst_thumbnailcache.cpp @@ -0,0 +1,339 @@ +#include "thumbnailcache.h" + +#include +#include +#include +#include +#include +#include + +using namespace catalog; + +namespace { + +constexpr qint64 kT0 = 1'700'000'000'000LL; + +// Stand-in for an encoded JPEG. Content doesn't matter, only that it round +// trips byte for byte — a BLOB column that mangles data would be silent. +QByteArray fakeImage(int sizeBytes, char fill = 'x') +{ + return QByteArray(sizeBytes, fill); +} + +ThumbKey keyFor(qint64 textureId, int size = 256) +{ + ThumbKey key; + key.textureId = textureId; + key.size = size; + return key; +} + +} // namespace + +class TestThumbnailCache : public QObject { + Q_OBJECT + +private slots: + void init(); + void cleanup(); + + void createsSchemaWithTunedPragmas(); + void putGetRoundTripsExactBytes(); + void realJpegSurvivesStoreAndDecode(); + void missReturnsEmpty(); + void sizeAndMeshArePartOfTheKey(); + void putOverwritesExistingVariant(); + void putRejectsEmptyOrUnkeyedImages(); + void putBatchWritesAll(); + + void removeTextureDropsEveryVariant(); + + void getDoesNotWriteLastUsed(); + void touchUpdatesLastUsed(); + + void evictionRemovesLeastRecentlyUsedFirst(); + void evictionIsNoOpUnderBudget(); + + void rebuildsWhenSchemaVersionDiffers(); + void rebuildsWhenFileIsCorrupt(); + void deletingFileWhileClosedIsRecoverable(); + +private: + QString cachePath() const { return dir->filePath(QStringLiteral("thumbs.db")); } + + QScopedPointer dir; + QScopedPointer cache; +}; + +void TestThumbnailCache::init() +{ + dir.reset(new QTemporaryDir); + QVERIFY(dir->isValid()); + cache.reset(new ThumbnailCache); + QVERIFY(cache->open(cachePath())); +} + +void TestThumbnailCache::cleanup() +{ + cache.reset(); + dir.reset(); +} + +void TestThumbnailCache::createsSchemaWithTunedPragmas() +{ + QVERIFY(cache->isOpen()); + QCOMPARE(cache->rowCount(), 0); + QCOMPARE(cache->totalBytes(), 0LL); + + // Both of these can only be set on an empty file, so if the schema were + // ever created before the PRAGMAs they'd silently revert to the defaults + // and incremental_vacuum would become a no-op. + Database raw; + QVERIFY(raw.open(cachePath())); + QCOMPARE(raw.scalar(QStringLiteral("PRAGMA page_size")), 8192LL); + QCOMPARE(raw.scalar(QStringLiteral("PRAGMA auto_vacuum")), 2LL); +} + +void TestThumbnailCache::putGetRoundTripsExactBytes() +{ + const QByteArray image = fakeImage(4096, '\x1'); + const ThumbKey key = keyFor(1); + + QVERIFY(cache->put(key, image, ThumbSource::Save, kT0)); + QVERIFY(cache->contains(key)); + QCOMPARE(cache->get(key), image); + QCOMPARE(cache->rowCount(), 1); + QCOMPARE(cache->totalBytes(), 4096LL); +} + +void TestThumbnailCache::realJpegSurvivesStoreAndDecode() +{ + // The capture path end to end minus the GL grab: encode a real image the + // way CatalogService::captureThumbnail does, store it, then decode it back + // the way the model does. A BLOB column that truncated or re-encoded would + // show up as a garbled card, which is hard to attribute after the fact. + QImage source(256, 256, QImage::Format_RGB32); + for (int y = 0; y < source.height(); ++y) + for (int x = 0; x < source.width(); ++x) + source.setPixel(x, y, qRgb(x, y, (x ^ y) & 0xFF)); + + QByteArray encoded; + QBuffer buffer(&encoded); + QVERIFY(buffer.open(QIODevice::WriteOnly)); + QVERIFY(source.save(&buffer, "JPG", 85)); + QVERIFY(!encoded.isEmpty()); + + const ThumbKey key = keyFor(7); + QVERIFY(cache->put(key, encoded, ThumbSource::Save, kT0)); + + const QByteArray fetched = cache->get(key); + QCOMPARE(fetched, encoded); + + // QImage, not QPixmap: a pixmap needs a QGuiApplication and this suite runs + // guiless. The decode path is what's under test either way. + QImage back; + QVERIFY(back.loadFromData(fetched, "JPG")); + QCOMPARE(back.size(), QSize(256, 256)); + + // Lossy, so compare structure rather than exact pixels: a black or + // transposed image would fail this while surviving a byte comparison. + QVERIFY(qAbs(qRed(back.pixel(200, 10)) - 200) < 24); + QVERIFY(qAbs(qGreen(back.pixel(10, 200)) - 200) < 24); +} + +void TestThumbnailCache::missReturnsEmpty() +{ + QVERIFY(cache->get(keyFor(2)).isEmpty()); + QVERIFY(!cache->contains(keyFor(2))); + + // An unkeyed request is a miss, not a crash. + QVERIFY(cache->get(ThumbKey()).isEmpty()); + QVERIFY(!cache->contains(ThumbKey())); +} + +void TestThumbnailCache::sizeAndMeshArePartOfTheKey() +{ + QVERIFY(cache->put(keyFor(1, 256), fakeImage(100, 'a'), ThumbSource::Save, kT0)); + QVERIFY(cache->put(keyFor(1, 512), fakeImage(200, 'b'), ThumbSource::Save, kT0)); + + ThumbKey otherMesh = keyFor(1, 256); + otherMesh.mesh = QStringLiteral("cube"); + QVERIFY(cache->put(otherMesh, fakeImage(300, 'c'), ThumbSource::Save, kT0)); + + QCOMPARE(cache->rowCount(), 3); + QCOMPARE(cache->get(keyFor(1, 256)).at(0), 'a'); + QCOMPARE(cache->get(keyFor(1, 512)).at(0), 'b'); + QCOMPARE(cache->get(otherMesh).at(0), 'c'); +} + +void TestThumbnailCache::putOverwritesExistingVariant() +{ + const ThumbKey key = keyFor(1); + QVERIFY(cache->put(key, fakeImage(100, 'a'), ThumbSource::Open, kT0)); + QVERIFY(cache->put(key, fakeImage(200, 'b'), ThumbSource::Save, kT0 + 1000)); + + // A better capture supersedes a cheaper one rather than adding a row. + QCOMPARE(cache->rowCount(), 1); + QCOMPARE(cache->get(key).size(), 200); + QCOMPARE(cache->get(key).at(0), 'b'); +} + +void TestThumbnailCache::putRejectsEmptyOrUnkeyedImages() +{ + QVERIFY(!cache->put(keyFor(1), QByteArray(), ThumbSource::Save, kT0)); + QVERIFY(!cache->put(ThumbKey(), fakeImage(100), ThumbSource::Save, kT0)); + QCOMPARE(cache->rowCount(), 0); +} + +void TestThumbnailCache::putBatchWritesAll() +{ + // One transaction per thumbnail means one fsync per thumbnail; the batch + // path exists so a bulk write doesn't crawl. + QVector entries; + for (int i = 0; i < 25; ++i) { + ThumbnailCache::Entry entry; + entry.key = keyFor(i); + entry.bytes = fakeImage(512); + entries << entry; + } + + QVERIFY(cache->putBatch(entries, kT0)); + QCOMPARE(cache->rowCount(), 25); + QCOMPARE(cache->totalBytes(), 25LL * 512); +} + +void TestThumbnailCache::removeTextureDropsEveryVariant() +{ + // How a stale thumbnail is invalidated now that there's no content hash: + // reconciliation sees size or mtime differ and drops the texture's images + // outright. Every variant goes, not just the size that happened to be on + // screen. + QVERIFY(cache->put(keyFor(1, 256), fakeImage(100), ThumbSource::Save, kT0)); + QVERIFY(cache->put(keyFor(1, 512), fakeImage(100), ThumbSource::Save, kT0)); + QVERIFY(cache->put(keyFor(2, 256), fakeImage(100), ThumbSource::Save, kT0)); + + QCOMPARE(cache->removeTexture(1), 2); + QCOMPARE(cache->rowCount(), 1); + QVERIFY(cache->contains(keyFor(2, 256))); + + QCOMPARE(cache->removeTexture(-1), 0); + QCOMPARE(cache->removeTexture(999), 0); +} + +void TestThumbnailCache::getDoesNotWriteLastUsed() +{ + // get() runs during scroll. A write per painted card is exactly what the + // "no disk writes on the GUI thread" rule forbids, so reads must be pure. + const ThumbKey key = keyFor(1); + QVERIFY(cache->put(key, fakeImage(100), ThumbSource::Save, kT0)); + + Database raw; + QVERIFY(raw.open(cachePath())); + const qint64 before = raw.scalar(QStringLiteral("SELECT last_used FROM thumb")); + + for (int i = 0; i < 10; ++i) + QVERIFY(!cache->get(key).isEmpty()); + + QCOMPARE(raw.scalar(QStringLiteral("SELECT last_used FROM thumb")), before); +} + +void TestThumbnailCache::touchUpdatesLastUsed() +{ + const ThumbKey key = keyFor(1); + QVERIFY(cache->put(key, fakeImage(100), ThumbSource::Save, kT0)); + + QVERIFY(cache->touch({key}, kT0 + 86'400'000)); + + Database raw; + QVERIFY(raw.open(cachePath())); + QCOMPARE(raw.scalar(QStringLiteral("SELECT last_used FROM thumb")), kT0 + 86'400'000); + + QVERIFY(cache->touch({}, kT0)); // empty batch is fine +} + +void TestThumbnailCache::evictionRemovesLeastRecentlyUsedFirst() +{ + // Ten 10 KiB images, each used a day apart. + for (int i = 0; i < 10; ++i) { + const ThumbKey key = keyFor(i); + QVERIFY(cache->put(key, fakeImage(10 * 1024), ThumbSource::Save, + kT0 + qint64(i) * 86'400'000)); + } + QCOMPARE(cache->totalBytes(), 10LL * 10 * 1024); + + // Trim to roughly half. + const int deleted = cache->evictTo(50 * 1024); + QCOMPARE(deleted, 5); + QCOMPARE(cache->rowCount(), 5); + QVERIFY(cache->totalBytes() <= 50 * 1024); + + // The five that survived are the five most recently used. + for (int i = 0; i < 5; ++i) + QVERIFY(!cache->contains(keyFor(i))); + for (int i = 5; i < 10; ++i) + QVERIFY(cache->contains(keyFor(i))); +} + +void TestThumbnailCache::evictionIsNoOpUnderBudget() +{ + QVERIFY(cache->put(keyFor(1), fakeImage(1024), ThumbSource::Save, kT0)); + + QCOMPARE(cache->evictTo(ThumbnailCache::DefaultBudgetBytes), 0); + QCOMPARE(cache->rowCount(), 1); +} + +void TestThumbnailCache::rebuildsWhenSchemaVersionDiffers() +{ + // A cache has no history worth migrating, so a schema from another build + // is thrown away rather than upgraded. This is the difference that makes + // thumbs.db disposable and index.db not. + QVERIFY(cache->put(keyFor(1), fakeImage(1024), ThumbSource::Save, kT0)); + cache->close(); + + { + Database raw; + QVERIFY(raw.open(cachePath())); + QVERIFY(raw.exec(QStringLiteral("UPDATE meta SET v = '99' WHERE k = 'schema_version'"))); + } + + QVERIFY(cache->open(cachePath())); + QVERIFY(cache->isOpen()); + QCOMPARE(cache->rowCount(), 0); + + // And it's usable immediately afterwards. + QVERIFY(cache->put(keyFor(1), fakeImage(1024), ThumbSource::Save, kT0)); + QCOMPARE(cache->rowCount(), 1); +} + +void TestThumbnailCache::rebuildsWhenFileIsCorrupt() +{ + cache->close(); + + { + QFile file(cachePath()); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); + file.write("this is not a database, it is a picture of a database"); + } + + QVERIFY(cache->open(cachePath())); + QVERIFY(cache->isOpen()); + QVERIFY(cache->put(keyFor(1), fakeImage(1024), ThumbSource::Save, kT0)); + QCOMPARE(cache->rowCount(), 1); +} + +void TestThumbnailCache::deletingFileWhileClosedIsRecoverable() +{ + // "Deleting thumbs.db degrades gracefully" from the acceptance criteria. + QVERIFY(cache->put(keyFor(1), fakeImage(1024), ThumbSource::Save, kT0)); + cache->close(); + + QVERIFY(QFile::remove(cachePath())); + + QVERIFY(cache->open(cachePath())); + QCOMPARE(cache->rowCount(), 0); + QVERIFY(cache->get(keyFor(1)).isEmpty()); + QVERIFY(cache->put(keyFor(1), fakeImage(1024), ThumbSource::Save, kT0)); +} + +QTEST_GUILESS_MAIN(TestThumbnailCache) +#include "tst_thumbnailcache.moc" From a148d4ee94401959d1f59184c7288aa8429ac82e Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Thu, 6 Aug 2026 14:40:14 -0500 Subject: [PATCH 149/164] reparent upgrade dialog --- src/texturelab/mainwindow.cpp | 20 +++++++++++++++----- src/texturelab/mainwindow.h | 9 +++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index c0693162..63d436aa 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -736,6 +736,16 @@ ads::CDockAreaWidget* MainWindow::addDock(const QString& title, return newAreaWidget; } +QWidget* MainWindow::dialogParent() +{ + // The launcher is a top-level window, not a child of this one, so it has to + // be named explicitly as the parent while it's the window on screen. + if (launcher && launcher->isVisible()) + return launcher; + + return this; +} + void MainWindow::captureLauncherThumbnail(int source) { CatalogService& catalog = CatalogService::instance(); @@ -823,7 +833,7 @@ void MainWindow::openProject() if (!promptSaveIfDirty()) return; - auto filePath = QFileDialog::getOpenFileName(this, "Open Texture File", "", + auto filePath = QFileDialog::getOpenFileName(dialogParent(), "Open Texture File", "", "Texturelab File (*.texture)"); if (filePath.isNull() || filePath.isEmpty()) @@ -839,7 +849,7 @@ void MainWindow::openProjectFromPath(const QString& filePath) QFile file(filePath); if (!file.open(QIODevice::ReadOnly)) { - QMessageBox::warning(this, "Open Texture", + QMessageBox::warning(dialogParent(), "Open Texture", "Could not open file:\n" + filePath); return; } @@ -854,7 +864,7 @@ void MainWindow::openProjectFromPath(const QString& filePath) chain << libVersionToString(v); auto choice = QMessageBox::question( - this, "Upgrade Texture?", + dialogParent(), "Upgrade Texture?", QString("This texture was created with library version %1.\n\n" "Upgrade it to %2 (%3) to use the latest nodes and " "improvements? A few node behaviors may change slightly.") @@ -939,7 +949,7 @@ void MainWindow::upgradeCurrentProjectLibrary() return; auto choice = QMessageBox::warning( - this, "Upgrade Library Version?", + dialogParent(), "Upgrade Library Version?", "Upgrading the library version is irreversible and clears the " "undo/redo history for this session.\n\n" "Save your project (or save a copy) first if you want to keep the " @@ -1265,7 +1275,7 @@ bool MainWindow::promptSaveIfDirty() QString name = project ? project->name : "Untitled"; auto choice = QMessageBox::question( - this, "Unsaved Changes", + dialogParent(), "Unsaved Changes", QString("Save changes to \"%1\" before continuing?").arg(name), QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel); diff --git a/src/texturelab/mainwindow.h b/src/texturelab/mainwindow.h index d9cdab56..85cc1f69 100644 --- a/src/texturelab/mainwindow.h +++ b/src/texturelab/mainwindow.h @@ -62,6 +62,15 @@ class MainWindow : public QMainWindow { void directExport(); void handleExport(const QString& destination, const QString& pattern); + // The window a modal should belong to right now. + // + // Opening a texture from the launcher runs through MainWindow while + // MainWindow itself is still hidden, and a dialog parented to a hidden + // widget has nothing to center on — Qt drops it on whichever screen that + // widget nominally lives on, which on a multi-monitor desk is usually not + // the one the user is looking at. + QWidget* dialogParent(); + // Grabs the 3D viewport and stores it as this texture's launcher thumbnail. // No-op when the viewport has never initialized its GL context, or when the // texture isn't in the index. From 7fd3f0bfaee3545c58352f088c5e34a1e1b7c747 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 29 Aug 2026 14:57:44 -0500 Subject: [PATCH 150/164] implement update checking --- resources/qss/app.qss.in | 12 ++ src/texturelab/CMakeLists.txt | 7 +- src/texturelab/launcher/launcherwindow.cpp | 88 ++++++++ src/texturelab/launcher/launcherwindow.h | 8 + src/texturelab/update/updatechecker.cpp | 224 +++++++++++++++++++++ src/texturelab/update/updatechecker.h | 75 +++++++ src/texturelab/update/versioncompare.cpp | 133 ++++++++++++ src/texturelab/update/versioncompare.h | 32 +++ tests/CMakeLists.txt | 19 +- tests/tst_updateendpoint.cpp | 141 +++++++++++++ tests/tst_versioncompare.cpp | 104 ++++++++++ 11 files changed, 841 insertions(+), 2 deletions(-) create mode 100644 src/texturelab/update/updatechecker.cpp create mode 100644 src/texturelab/update/updatechecker.h create mode 100644 src/texturelab/update/versioncompare.cpp create mode 100644 src/texturelab/update/versioncompare.h create mode 100644 tests/tst_updateendpoint.cpp create mode 100644 tests/tst_versioncompare.cpp diff --git a/resources/qss/app.qss.in b/resources/qss/app.qss.in index e7c7c9a3..1b8dee86 100644 --- a/resources/qss/app.qss.in +++ b/resources/qss/app.qss.in @@ -485,3 +485,15 @@ QPushButton[size="small"] { font-size: 14px; } #StatusHomeButton:hover { color: {{text.primary}}; } + +/* Update notice — hidden until a newer release exists, so it only ever appears + when it has something to say. Accent-tinted rather than shouty. */ +#launcherUpdateButton { + background: {{accent}}; + border: none; + border-radius: {{radius.sm}}px; + color: {{white}}; + padding: 4px 10px; + font-size: 12px; +} +#launcherUpdateButton:hover { background: {{accent.hover}}; } diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 1920fccd..7d0eef2e 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -19,7 +19,7 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets OpenGL) -find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets OpenGLWidgets) +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets OpenGLWidgets Network) find_package(OpenGL REQUIRED) set(LIBRARYV1 @@ -168,6 +168,10 @@ set(PROJECT_SOURCES ./launcher/texturerowdelegate.cpp ./launcher/launcherformat.h ./launcher/launcherformat.cpp + ./update/versioncompare.h + ./update/versioncompare.cpp + ./update/updatechecker.h + ./update/updatechecker.cpp ./mainwindow.cpp ./mainwindow.h ./clipboard.h @@ -271,6 +275,7 @@ endif() # note: openglwidgets is qt6 only target_link_libraries(texturelab PRIVATE Qt${QT_VERSION_MAJOR}::Widgets + Qt${QT_VERSION_MAJOR}::Network Qt${QT_VERSION_MAJOR}::OpenGL Qt${QT_VERSION_MAJOR}::OpenGLWidgets OpenGL::GL diff --git a/src/texturelab/launcher/launcherwindow.cpp b/src/texturelab/launcher/launcherwindow.cpp index 1a8377c6..0ec534cd 100644 --- a/src/texturelab/launcher/launcherwindow.cpp +++ b/src/texturelab/launcher/launcherwindow.cpp @@ -5,6 +5,7 @@ #include "texturecarddelegate.h" #include "texturelistmodel.h" #include "texturerowdelegate.h" +#include "update/updatechecker.h" #include #include @@ -25,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +40,7 @@ constexpr const char* kActionBarName = "launcherActionBar"; constexpr const char* kGridName = "launcherGrid"; constexpr const char* kFilterTabName = "launcherFilterTab"; constexpr const char* kEmptyLabelName = "launcherEmptyLabel"; +constexpr const char* kUpdateButtonName = "launcherUpdateButton"; bool isTextureFile(const QUrl& url) { @@ -112,6 +115,9 @@ LauncherWindow::LauncherWindow(QWidget* parent) : QWidget(parent) // Last, so it can drive widgets the two build* methods created. restoreViewState(); updateEmptyState(); + + updates = new UpdateChecker(this); + connect(updates, &UpdateChecker::updateAvailable, this, &LauncherWindow::showUpdateNotice); } LauncherWindow::~LauncherWindow() = default; @@ -179,10 +185,64 @@ QWidget* LauncherWindow::buildTopBar() // }); // layout->addWidget(sortBox); + // Update notice: absent entirely until a newer release exists, so the bar + // stays quiet in the normal case. + updateButton = new QToolButton(bar); + updateButton->setObjectName(QLatin1String(kUpdateButtonName)); + updateButton->setCursor(Qt::PointingHandCursor); + updateButton->setVisible(false); + layout->addWidget(updateButton); + auto* gear = new QToolButton(bar); gear->setText(QStringLiteral("⚙")); gear->setPopupMode(QToolButton::InstantPopup); auto* menu = new QMenu(gear); + menu->addAction(QStringLiteral("Check for Updates…"), this, [this]() { + if (!UpdateChecker::isEnabled()) { + QMessageBox::information( + this, tr("Check for Updates"), + tr("Update checks are turned off.\n\nTurn them back on from this menu to let " + "TextureLab ask texturelab.io whether a newer build exists.")); + return; + } + // A manual check has to say something either way; the automatic one + // stays silent unless there's news. + connect( + updates, &UpdateChecker::checkFinished, this, + [this](bool found, const QString& error) { + if (found) + return; // the notice in the bar is the answer + // Both outcomes name the endpoint that was actually used. It is + // set at compile time and overridable by the environment, so + // "which server did it ask?" is otherwise unanswerable from + // inside the app — and that is exactly the question you have + // when a dev server sees no traffic. + const QString endpoint = + tr("Checked: %1 (%2 channel)") + .arg(UpdateChecker::apiBase(), UpdateChecker::channel()); + + if (error.isEmpty()) { + QMessageBox::information( + this, tr("Check for Updates"), + tr("TextureLab is up to date.\n\n%1").arg(endpoint)); + } + else { + QMessageBox::warning(this, tr("Check for Updates"), + tr("Could not reach the update server.\n\n%1\n\n%2") + .arg(error, endpoint)); + } + }, + Qt::SingleShotConnection); + + updates->check(/*force=*/true); + }); + + auto* toggleChecks = menu->addAction(QStringLiteral("Check for Updates Automatically")); + toggleChecks->setCheckable(true); + toggleChecks->setChecked(UpdateChecker::isEnabled()); + connect(toggleChecks, &QAction::toggled, this, [](bool on) { UpdateChecker::setEnabled(on); }); + + menu->addSeparator(); menu->addAction(QStringLiteral("Clear Missing Textures"), this, [this]() { CatalogService& catalog = CatalogService::instance(); if (!catalog.isReady()) @@ -408,6 +468,34 @@ void LauncherWindow::showEvent(QShowEvent* event) QWidget::showEvent(event); refresh(); search->setFocus(); + + // Throttled internally, so reopening the launcher from the Home button all + // day costs one request every few hours. + if (updates) + updates->check(); +} + +void LauncherWindow::showUpdateNotice(const QString& version, const QString& title, + const QString& downloadUrl) +{ + if (!updateButton) + return; + + updateButton->setText(tr("Update to %1").arg(version)); + + QStringList tip; + if (!title.isEmpty()) + tip << title; + tip << downloadUrl; + tip << tr("via %1").arg(UpdateChecker::apiBase()); + updateButton->setToolTip(tip.join(QLatin1Char('\n'))); + updateButton->setVisible(true); + + // Opening the browser is the whole action — the launcher never downloads or + // installs anything on the user's behalf. + disconnect(updateButton, &QToolButton::clicked, nullptr, nullptr); + connect(updateButton, &QToolButton::clicked, this, + [downloadUrl]() { QDesktopServices::openUrl(QUrl(downloadUrl)); }); } void LauncherWindow::openSelected() diff --git a/src/texturelab/launcher/launcherwindow.h b/src/texturelab/launcher/launcherwindow.h index 51255ce3..94334cb7 100644 --- a/src/texturelab/launcher/launcherwindow.h +++ b/src/texturelab/launcher/launcherwindow.h @@ -15,6 +15,7 @@ class QToolButton; class TextureCardDelegate; class TextureListModel; class TextureRowDelegate; +class UpdateChecker; // The launcher: a flat grid of every texture the app has touched. // @@ -46,6 +47,11 @@ class LauncherWindow : public QWidget { public slots: void refresh(); + // Reveals the "Update to X" affordance in the top bar. Nothing is + // downloaded; clicking it opens the release page in the browser. + void showUpdateNotice(const QString& version, const QString& title, + const QString& downloadUrl); + protected: void keyPressEvent(QKeyEvent* event) override; void closeEvent(QCloseEvent* event) override; @@ -71,6 +77,7 @@ public slots: void toggleStarOnSelection(); void removeSelectionFromLauncher(); + UpdateChecker* updates = nullptr; TextureListModel* model = nullptr; TextureCardDelegate* cardDelegate = nullptr; TextureRowDelegate* rowDelegate = nullptr; @@ -81,6 +88,7 @@ public slots: QSlider* sizeSlider = nullptr; QLabel* emptyLabel = nullptr; QPushButton* openButton = nullptr; + QToolButton* updateButton = nullptr; QToolButton* gridToggle = nullptr; QToolButton* listToggle = nullptr; QToolButton* allTab = nullptr; diff --git a/src/texturelab/update/updatechecker.cpp b/src/texturelab/update/updatechecker.cpp new file mode 100644 index 00000000..9e2d0228 --- /dev/null +++ b/src/texturelab/update/updatechecker.cpp @@ -0,0 +1,224 @@ +#include "updatechecker.h" + +#include "telemetry.h" +#include "versioncompare.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// The update endpoint. Hardcoded on purpose — no build flag, no environment +// override — so what the app asks is whatever this one line says. +// +// !!! Currently pointed at the local dev server. Set this back to +// !!! "https://texturelab.io" before cutting a release: a shipped build pointed +// !!! at localhost never reaches anything and silently reports no updates. +constexpr const char* kApiBase = "http://localhost:3333"; + +constexpr const char* kEnabledKey = "updateCheck"; + +// The last release the server told us about. Remembered so the launcher can say +// so on every open, not just during the minutes after a check completes. +constexpr const char* kKnownVersionKey = "updateKnownVersion"; +constexpr const char* kKnownTitleKey = "updateKnownTitle"; +constexpr const char* kKnownUrlKey = "updateKnownUrl"; + +QSettings appSettings() +{ + return QSettings(QSettings::UserScope, "texturelab", "texturelab"); +} + +} // namespace + +UpdateChecker::UpdateChecker(QObject* parent) : QObject(parent) {} + +UpdateChecker::~UpdateChecker() = default; + +QString UpdateChecker::apiBase() +{ + // Trailing slashes are trimmed rather than assumed absent: the request path + // is appended directly, and "…3333/" + "/api/…" is a double-slashed path + // that some routers answer with a 404. + QString base = QString::fromLatin1(kApiBase).trimmed(); + while (base.endsWith(QLatin1Char('/'))) + base.chop(1); + + return base; +} + +bool UpdateChecker::isEnabled() +{ + return appSettings().value(QLatin1String(kEnabledKey), true).toBool(); +} + +void UpdateChecker::setEnabled(bool enabled) +{ + QSettings settings = appSettings(); + settings.setValue(QLatin1String(kEnabledKey), enabled); +} + +QString UpdateChecker::channel() +{ + const QString stored = appSettings().value(QStringLiteral("updateChannel")).toString(); + if (stored == QLatin1String("stable") || stored == QLatin1String("beta")) + return stored; + + // A pre-release tag on our own version means this is a beta build, and its + // user is better served by beta releases than by being told nothing exists. + const QString self = appversion::normalize(QCoreApplication::applicationVersion()); + return self.contains(QLatin1Char('-')) ? QStringLiteral("beta") : QStringLiteral("stable"); +} + +QString UpdateChecker::platformKey() +{ +#if defined(Q_OS_WIN) + return QStringLiteral("windows"); +#elif defined(Q_OS_MACOS) + return QStringLiteral("mac"); +#else + return QStringLiteral("linux"); +#endif +} + +QString UpdateChecker::knownUpdateVersion() +{ + const QString version = appSettings().value(QLatin1String(kKnownVersionKey)).toString(); + if (version.isEmpty()) + return QString(); + + // A remembered update the running build has caught up with is stale — the + // user updated, and nothing should still be nagging them. + if (!appversion::isNewer(version, QCoreApplication::applicationVersion())) + return QString(); + + return version; +} + +void UpdateChecker::rememberUpdate(const QString& version, const QString& title, + const QString& url) +{ + QSettings settings = appSettings(); + settings.setValue(QLatin1String(kKnownVersionKey), version); + settings.setValue(QLatin1String(kKnownTitleKey), title); + settings.setValue(QLatin1String(kKnownUrlKey), url); +} + +void UpdateChecker::forgetUpdate() +{ + QSettings settings = appSettings(); + settings.remove(QLatin1String(kKnownVersionKey)); + settings.remove(QLatin1String(kKnownTitleKey)); + settings.remove(QLatin1String(kKnownUrlKey)); +} + +void UpdateChecker::check(bool force) +{ + if (!isEnabled() || inFlight) + return; + + QSettings settings = appSettings(); + + // Say what we already know before deciding whether to ask again. + const QString known = knownUpdateVersion(); + if (!known.isEmpty()) { + emit updateAvailable(known, settings.value(QLatin1String(kKnownTitleKey)).toString(), + settings.value(QLatin1String(kKnownUrlKey)).toString()); + } + + // Once per run unless explicitly forced. + if (!force && checkedThisRun) + return; + + QUrl url(apiBase() + QStringLiteral("/api/releases/latest")); + QUrlQuery query; + query.addQueryItem(QStringLiteral("channel"), channel()); + url.setQuery(query); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, + QStringLiteral("TextureLab/%1").arg(QCoreApplication::applicationVersion())); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + request.setTransferTimeout(8000); + + if (!network) + network = new QNetworkAccessManager(this); + + inFlight = true; + checkedThisRun = true; + + QNetworkReply* reply = network->get(request); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { handleReply(reply); }); +} + +void UpdateChecker::handleReply(QNetworkReply* reply) +{ + inFlight = false; + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + // Being offline is the normal case, not an incident: report it to the + // caller and leave no trace beyond a breadcrumb. + const QString error = reply->errorString(); + Telemetry::breadcrumb("update", "check failed: " + error.toStdString()); + emit checkFinished(false, error); + return; + } + + const QByteArray body = reply->readAll(); + + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(body, &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { + emit checkFinished(false, QStringLiteral("Malformed response from the update server")); + return; + } + + // 404 with {"error": ...} is a legitimate answer: the channel has no + // published release yet. + const QJsonObject root = doc.object(); + if (!root.value(QStringLiteral("data")).isObject()) { + const QString error = root.value(QStringLiteral("error")).toString(); + emit checkFinished(false, error); + return; + } + + const QJsonObject data = root.value(QStringLiteral("data")).toObject(); + const QString version = data.value(QStringLiteral("version")).toString(); + if (version.isEmpty()) { + emit checkFinished(false, QStringLiteral("Update server returned no version")); + return; + } + + const QString current = QCoreApplication::applicationVersion(); + if (!appversion::isNewer(version, current)) { + // Up to date now — drop anything remembered from before, so a notice + // can't outlive the update it referred to. + forgetUpdate(); + emit checkFinished(false, QString()); + return; + } + + const QJsonObject downloads = data.value(QStringLiteral("downloads")).toObject(); + QString downloadUrl = downloads.value(platformKey()).toString(); + + // No build for this platform yet — still worth telling them, pointed at the + // page rather than at nothing. + if (downloadUrl.isEmpty()) + downloadUrl = apiBase() + QStringLiteral("/#download"); + + Telemetry::breadcrumb("update", "found " + version.toStdString()); + + rememberUpdate(version, data.value(QStringLiteral("title")).toString(), downloadUrl); + + emit updateAvailable(version, data.value(QStringLiteral("title")).toString(), downloadUrl); + emit checkFinished(true, QString()); +} diff --git a/src/texturelab/update/updatechecker.h b/src/texturelab/update/updatechecker.h new file mode 100644 index 00000000..e89c7bee --- /dev/null +++ b/src/texturelab/update/updatechecker.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include + +class QNetworkAccessManager; + +// Asks texturelab.io whether a newer build exists. +// +// Read-only and fire-and-forget: one GET to a public, unauthenticated endpoint, +// no payload, no identifiers beyond what any HTTP request carries. It never +// downloads or installs anything — finding an update just surfaces a link the +// user can choose to click. +// +// Opt-out lives in QSettings under "updateCheck", matching how crash reporting +// is handled. Off means no request is made at all, not a discarded response. +class UpdateChecker : public QObject { + Q_OBJECT + +public: + explicit UpdateChecker(QObject* parent = nullptr); + ~UpdateChecker() override; + + // Endpoint base, without a trailing slash. Hardcoded in updatechecker.cpp; + // point it at a dev server by editing that constant and rebuilding. + static QString apiBase(); + + static bool isEnabled(); + static void setEnabled(bool enabled); + + // "stable" or "beta". Defaults to beta when this build's own version + // carries a pre-release tag — someone running a beta wants beta news. + static QString channel(); + + // Surfaces any already-known update immediately, then asks the server — + // once per application run. Reopening the launcher from the Home button + // reuses the answer from the first check rather than asking again. + // + // `force` overrides that, for the menu-driven "check now". + // + // The two halves are independent on purpose: how often the server is asked + // is not how often the user is told. Without the first half, an update + // found at startup would stop being displayed the moment the launcher was + // closed and reopened. + void check(bool force = false); + + // The newest release seen by a previous check, remembered across restarts. + // Empty when there is none, or when this build has caught up with it. + static QString knownUpdateVersion(); + +signals: + // Emitted only when the published version is strictly newer than this one. + // `downloadUrl` is the build for this platform, falling back to the + // releases page when the server has no URL for it. + void updateAvailable(const QString& version, const QString& title, + const QString& downloadUrl); + + // Emitted on every completed check, including "you're up to date" and + // failures, so a manual check can report something either way. + void checkFinished(bool foundUpdate, const QString& error); + +private: + void handleReply(class QNetworkReply* reply); + static void rememberUpdate(const QString& version, const QString& title, const QString& url); + static void forgetUpdate(); + static QString platformKey(); + + QNetworkAccessManager* network = nullptr; + bool inFlight = false; + + // One automatic check per run. The endpoint sends Cache-Control: + // max-age=300 and a launch is not a frequent event, so there is nothing to + // gain from asking twice in a session. + bool checkedThisRun = false; +}; diff --git a/src/texturelab/update/versioncompare.cpp b/src/texturelab/update/versioncompare.cpp new file mode 100644 index 00000000..e7ec9887 --- /dev/null +++ b/src/texturelab/update/versioncompare.cpp @@ -0,0 +1,133 @@ +#include "versioncompare.h" + +#include + +namespace appversion { + +namespace { + +struct Parsed { + int major = 0; + int minor = 0; + int patch = 0; + QString preRelease; +}; + +int toInt(const QString& text) +{ + bool ok = false; + const int value = text.toInt(&ok); + return ok && value >= 0 ? value : 0; +} + +Parsed parse(const QString& version) +{ + Parsed out; + + QString core = normalize(version); + + const int dash = core.indexOf(QLatin1Char('-')); + if (dash >= 0) { + out.preRelease = core.mid(dash + 1); + core = core.left(dash); + } + + const QStringList parts = core.split(QLatin1Char('.')); + if (parts.size() > 0) + out.major = toInt(parts[0]); + if (parts.size() > 1) + out.minor = toInt(parts[1]); + if (parts.size() > 2) + out.patch = toInt(parts[2]); + + return out; +} + +// Semver identifier comparison: numeric identifiers compare numerically and +// always sort below alphanumeric ones; a shorter run of equal identifiers sorts +// lower. So beta < beta.2 < rc. +int comparePreRelease(const QString& a, const QString& b) +{ + if (a == b) + return 0; + + // Neither having a pre-release is handled by the caller; here, exactly one + // being empty means that one is the full release and therefore greater. + if (a.isEmpty()) + return 1; + if (b.isEmpty()) + return -1; + + const QStringList left = a.split(QLatin1Char('.')); + const QStringList right = b.split(QLatin1Char('.')); + + for (int i = 0; i < qMax(left.size(), right.size()); ++i) { + if (i >= left.size()) + return -1; + if (i >= right.size()) + return 1; + + const QString& l = left[i]; + const QString& r = right[i]; + + bool lNumeric = false; + bool rNumeric = false; + const int lValue = l.toInt(&lNumeric); + const int rValue = r.toInt(&rNumeric); + + if (lNumeric && rNumeric) { + if (lValue != rValue) + return lValue < rValue ? -1 : 1; + continue; + } + if (lNumeric != rNumeric) + return lNumeric ? -1 : 1; + + const int textual = QString::compare(l, r); + if (textual != 0) + return textual < 0 ? -1 : 1; + } + + return 0; +} + +} // namespace + +QString normalize(const QString& version) +{ + QString out = version.trimmed(); + + if (out.startsWith(QLatin1Char('v')) || out.startsWith(QLatin1Char('V'))) + out = out.mid(1); + + const int plus = out.indexOf(QLatin1Char('+')); + if (plus >= 0) + out = out.left(plus); + + return out; +} + +int compare(const QString& a, const QString& b) +{ + const Parsed left = parse(a); + const Parsed right = parse(b); + + if (left.major != right.major) + return left.major < right.major ? -1 : 1; + if (left.minor != right.minor) + return left.minor < right.minor ? -1 : 1; + if (left.patch != right.patch) + return left.patch < right.patch ? -1 : 1; + + if (left.preRelease.isEmpty() && right.preRelease.isEmpty()) + return 0; + + return comparePreRelease(left.preRelease, right.preRelease); +} + +bool isNewer(const QString& candidate, const QString& current) +{ + return compare(candidate, current) > 0; +} + +} // namespace appversion diff --git a/src/texturelab/update/versioncompare.h b/src/texturelab/update/versioncompare.h new file mode 100644 index 00000000..74fdae27 --- /dev/null +++ b/src/texturelab/update/versioncompare.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +// Semver comparison for "is the release on the site newer than this build?". +// +// Kept apart from UpdateChecker, and free of any dependency beyond QtCore, so +// the ordering rules can be tested without a network stack. Getting this wrong +// is quiet and bad in both directions: nagging users who are already current, +// or never telling anyone an update exists. +namespace appversion { + +// -1 if a < b, 0 if equal, 1 if a > b. +// +// Follows semver precedence, including the rule people get wrong: a release +// with a pre-release tag sorts *below* the same numbers without one, so +// 0.4.0-beta < 0.4.0. Build metadata after '+' is ignored entirely, which is +// what lets the app compare its own "0.4.0-beta+a1b2c3d" against the plain +// "0.4.0-beta" the API publishes. +// +// Missing numeric parts are zero, so "1.2" == "1.2.0". Non-numeric junk in a +// numeric field compares as 0 rather than throwing — a malformed version from +// the server should mean "no update", never a crash. +int compare(const QString& a, const QString& b); + +// True when `candidate` is strictly newer than `current`. +bool isNewer(const QString& candidate, const QString& current); + +// Strips build metadata and any leading 'v' — "v1.2.3+abc" becomes "1.2.3". +QString normalize(const QString& version); + +} // namespace appversion diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0f44b12a..b8d08e45 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -8,7 +8,7 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core) -find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Test) +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Test Gui Network) # Each test file becomes its own binary, so one crashing suite can't take the # others' results with it. @@ -29,6 +29,23 @@ texturelab_add_test(tst_thumbnailcache) # QImage/QPixmap round-trip needs the GUI module. target_link_libraries(tst_thumbnailcache PRIVATE Qt${QT_VERSION_MAJOR}::Gui) +# Update version ordering: pure QtCore, so it compiles straight into a test. +texturelab_add_test(tst_versioncompare) +target_sources(tst_versioncompare PRIVATE + ${CMAKE_SOURCE_DIR}/src/texturelab/update/versioncompare.cpp) +target_include_directories(tst_versioncompare PRIVATE + ${CMAKE_SOURCE_DIR}/src/texturelab/update) + +# Which server gets asked, and on which channel. Needs Network (the checker +# owns a QNetworkAccessManager) but makes no request. +texturelab_add_test(tst_updateendpoint) +target_sources(tst_updateendpoint PRIVATE + ${CMAKE_SOURCE_DIR}/src/texturelab/update/updatechecker.cpp + ${CMAKE_SOURCE_DIR}/src/texturelab/update/versioncompare.cpp) +target_include_directories(tst_updateendpoint PRIVATE + ${CMAKE_SOURCE_DIR}/src/texturelab/update + ${CMAKE_SOURCE_DIR}/src/texturelab) +target_link_libraries(tst_updateendpoint PRIVATE Qt${QT_VERSION_MAJOR}::Network) # The launcher's model is deliberately free of app dependencies — Qt plus the # catalog and nothing else — so its .cpp can be compiled straight into a test # without dragging in the node graph or an OpenGL context. diff --git a/tests/tst_updateendpoint.cpp b/tests/tst_updateendpoint.cpp new file mode 100644 index 00000000..a5e7e14e --- /dev/null +++ b/tests/tst_updateendpoint.cpp @@ -0,0 +1,141 @@ +#include "updatechecker.h" + +#include "telemetry.h" + +#include +#include +#include +#include + +// UpdateChecker leaves breadcrumbs; the real Telemetry pulls in Sentry and a +// generated version header, neither of which this test has an opinion about. +// Stubbing the one entry point it uses keeps the test to the code under test. +namespace Telemetry { +void breadcrumb(const char*, const std::string&) {} +} // namespace Telemetry + +// Covers what decides *which server gets asked* and on what channel — the +// question you have when a dev server sees no traffic. The endpoint itself is +// hardcoded, so the interesting assertions are that it stays well-formed and +// that nothing outside the source can redirect it. +class TestUpdateEndpoint : public QObject { + Q_OBJECT + +private slots: + void initTestCase(); + void cleanup(); + + void endpointIsAWellFormedAbsoluteUrl(); + void endpointIgnoresTheEnvironment(); + + void channelFollowsOwnVersion(); + void channelSettingWinsOverVersion(); + void nonsenseChannelSettingIsIgnored(); + + void remembersAKnownUpdateAcrossRestarts(); + void forgetsAKnownUpdateOnceTheBuildCatchesUp(); +}; + +void TestUpdateEndpoint::initTestCase() +{ + // Keep every settings write inside the test's own scope rather than the + // developer's real config. + QStandardPaths::setTestModeEnabled(true); +} + +void TestUpdateEndpoint::cleanup() +{ + qunsetenv("TEXTURELAB_API_BASE"); + + QSettings settings(QSettings::UserScope, "texturelab", "texturelab"); + settings.remove("updateChannel"); + settings.remove("updateKnownVersion"); +} + +void TestUpdateEndpoint::endpointIsAWellFormedAbsoluteUrl() +{ + // The request path is appended directly, so the base has to be absolute and + // free of a trailing slash — "…:3333/" + "/api/…" is a double-slashed path + // that some routers answer with a 404. + const QString base = UpdateChecker::apiBase(); + + QVERIFY(!base.isEmpty()); + QVERIFY(base.startsWith(QStringLiteral("http"))); + QVERIFY(!base.endsWith(QLatin1Char('/'))); + + const QUrl url(base + QStringLiteral("/api/releases/latest")); + QVERIFY(url.isValid()); + QCOMPARE(url.path(), QStringLiteral("/api/releases/latest")); +} + +void TestUpdateEndpoint::endpointIgnoresTheEnvironment() +{ + // The endpoint is hardcoded. It used to be overridable, and this asserts + // the override is really gone rather than quietly still honoured — a stale + // variable in someone's shell would otherwise redirect update checks. + const QString before = UpdateChecker::apiBase(); + + qputenv("TEXTURELAB_API_BASE", "http://example.invalid:1234"); + QCOMPARE(UpdateChecker::apiBase(), before); +} + +void TestUpdateEndpoint::channelFollowsOwnVersion() +{ + QCoreApplication::setApplicationVersion(QStringLiteral("0.4.0-beta+abc1234")); + QCOMPARE(UpdateChecker::channel(), QStringLiteral("beta")); + + QCoreApplication::setApplicationVersion(QStringLiteral("1.0.0+abc1234")); + QCOMPARE(UpdateChecker::channel(), QStringLiteral("stable")); +} + +void TestUpdateEndpoint::channelSettingWinsOverVersion() +{ + QCoreApplication::setApplicationVersion(QStringLiteral("0.4.0-beta")); + QSettings(QSettings::UserScope, "texturelab", "texturelab") + .setValue(QStringLiteral("updateChannel"), QStringLiteral("stable")); + + QCOMPARE(UpdateChecker::channel(), QStringLiteral("stable")); +} + +void TestUpdateEndpoint::nonsenseChannelSettingIsIgnored() +{ + QCoreApplication::setApplicationVersion(QStringLiteral("0.4.0-beta")); + QSettings(QSettings::UserScope, "texturelab", "texturelab") + .setValue(QStringLiteral("updateChannel"), QStringLiteral("nightly")); + + // The API only accepts stable|beta; anything else would come back 422. + QCOMPARE(UpdateChecker::channel(), QStringLiteral("beta")); +} + +void TestUpdateEndpoint::remembersAKnownUpdateAcrossRestarts() +{ + // The launcher shows the notice from this, not from a fresh request — the + // network check is throttled to once every few hours, and without a + // remembered answer the notice would vanish in between. + QCoreApplication::setApplicationVersion(QStringLiteral("0.4.0-beta+abc1234")); + + QSettings settings(QSettings::UserScope, "texturelab", "texturelab"); + settings.setValue(QStringLiteral("updateKnownVersion"), QStringLiteral("1.3.0-beta")); + + QCOMPARE(UpdateChecker::knownUpdateVersion(), QStringLiteral("1.3.0-beta")); +} + +void TestUpdateEndpoint::forgetsAKnownUpdateOnceTheBuildCatchesUp() +{ + QSettings settings(QSettings::UserScope, "texturelab", "texturelab"); + settings.setValue(QStringLiteral("updateKnownVersion"), QStringLiteral("1.3.0-beta")); + + // Running exactly the remembered version: nothing left to announce. + QCoreApplication::setApplicationVersion(QStringLiteral("1.3.0-beta+deadbee")); + QVERIFY(UpdateChecker::knownUpdateVersion().isEmpty()); + + // And past it, which is what a beta tester hits after installing. + QCoreApplication::setApplicationVersion(QStringLiteral("1.4.0")); + QVERIFY(UpdateChecker::knownUpdateVersion().isEmpty()); + + settings.remove(QStringLiteral("updateKnownVersion")); + QVERIFY(UpdateChecker::knownUpdateVersion().isEmpty()); +} + +QTEST_GUILESS_MAIN(TestUpdateEndpoint) +#include "tst_updateendpoint.moc" diff --git a/tests/tst_versioncompare.cpp b/tests/tst_versioncompare.cpp new file mode 100644 index 00000000..03b87e11 --- /dev/null +++ b/tests/tst_versioncompare.cpp @@ -0,0 +1,104 @@ +#include "versioncompare.h" + +#include + +using namespace appversion; + +class TestVersionCompare : public QObject { + Q_OBJECT + +private slots: + void normalizeStripsBuildMetadataAndPrefix(); + + void comparesNumericParts(); + void treatsMissingPartsAsZero(); + void preReleaseSortsBelowRelease(); + void comparesPreReleaseIdentifiers(); + + void thisBuildIsNotNewerThanItself(); + void betaBuildSeesMatchingStableRelease(); + void malformedInputNeverClaimsAnUpdate(); +}; + +void TestVersionCompare::normalizeStripsBuildMetadataAndPrefix() +{ + // The app's own version is "0.4.0-beta+"; the API publishes + // "0.4.0-beta". Without stripping the metadata every check would either + // compare unequal strings or mis-parse the hash as a version part. + QCOMPARE(normalize(QStringLiteral("0.4.0-beta+a1b2c3d")), QStringLiteral("0.4.0-beta")); + QCOMPARE(normalize(QStringLiteral("v1.2.3")), QStringLiteral("1.2.3")); + QCOMPARE(normalize(QStringLiteral(" 1.2.3 ")), QStringLiteral("1.2.3")); +} + +void TestVersionCompare::comparesNumericParts() +{ + QCOMPARE(compare(QStringLiteral("1.0.0"), QStringLiteral("1.0.0")), 0); + QVERIFY(isNewer(QStringLiteral("1.0.1"), QStringLiteral("1.0.0"))); + QVERIFY(isNewer(QStringLiteral("1.1.0"), QStringLiteral("1.0.9"))); + QVERIFY(isNewer(QStringLiteral("2.0.0"), QStringLiteral("1.9.9"))); + QVERIFY(!isNewer(QStringLiteral("1.0.0"), QStringLiteral("1.0.1"))); + + // Not a string comparison: "0.10.0" is newer than "0.9.0" even though it + // sorts lower lexically. This is the classic way this goes wrong. + QVERIFY(isNewer(QStringLiteral("0.10.0"), QStringLiteral("0.9.0"))); + QVERIFY(isNewer(QStringLiteral("0.4.10"), QStringLiteral("0.4.9"))); +} + +void TestVersionCompare::treatsMissingPartsAsZero() +{ + QCOMPARE(compare(QStringLiteral("1.2"), QStringLiteral("1.2.0")), 0); + QCOMPARE(compare(QStringLiteral("1"), QStringLiteral("1.0.0")), 0); + QVERIFY(isNewer(QStringLiteral("1.2.1"), QStringLiteral("1.2"))); +} + +void TestVersionCompare::preReleaseSortsBelowRelease() +{ + // Semver's rule, and the one that matters most here: shipping 0.4.0 final + // must register as an update for someone on 0.4.0-beta. + QVERIFY(isNewer(QStringLiteral("0.4.0"), QStringLiteral("0.4.0-beta"))); + QVERIFY(!isNewer(QStringLiteral("0.4.0-beta"), QStringLiteral("0.4.0"))); + QCOMPARE(compare(QStringLiteral("0.4.0-beta"), QStringLiteral("0.4.0-beta")), 0); +} + +void TestVersionCompare::comparesPreReleaseIdentifiers() +{ + QVERIFY(isNewer(QStringLiteral("1.0.0-beta.2"), QStringLiteral("1.0.0-beta.1"))); + QVERIFY(isNewer(QStringLiteral("1.0.0-beta.10"), QStringLiteral("1.0.0-beta.9"))); + QVERIFY(isNewer(QStringLiteral("1.0.0-rc"), QStringLiteral("1.0.0-beta"))); + + // A longer run of otherwise-equal identifiers is the higher version. + QVERIFY(isNewer(QStringLiteral("1.0.0-beta.1"), QStringLiteral("1.0.0-beta"))); + + // Numeric identifiers rank below alphanumeric ones. + QVERIFY(isNewer(QStringLiteral("1.0.0-alpha"), QStringLiteral("1.0.0-1"))); +} + +void TestVersionCompare::thisBuildIsNotNewerThanItself() +{ + // The exact shape the app compares at runtime: its own version string, with + // the build hash attached, against what the API would publish for it. + const QString running = QStringLiteral("0.4.0-beta+e759268"); + QVERIFY(!isNewer(QStringLiteral("0.4.0-beta"), running)); + QCOMPARE(compare(QStringLiteral("0.4.0-beta"), running), 0); +} + +void TestVersionCompare::betaBuildSeesMatchingStableRelease() +{ + const QString running = QStringLiteral("0.4.0-beta+e759268"); + QVERIFY(isNewer(QStringLiteral("0.4.0"), running)); + QVERIFY(isNewer(QStringLiteral("0.5.0-beta"), running)); +} + +void TestVersionCompare::malformedInputNeverClaimsAnUpdate() +{ + // A broken response should mean "no update", never a false prompt and never + // a crash. + const QString running = QStringLiteral("0.4.0-beta+e759268"); + QVERIFY(!isNewer(QString(), running)); + QVERIFY(!isNewer(QStringLiteral("not-a-version"), running)); + QVERIFY(!isNewer(QStringLiteral("...."), running)); + QVERIFY(!isNewer(QStringLiteral("0.0.0"), running)); +} + +QTEST_GUILESS_MAIN(TestVersionCompare) +#include "tst_versioncompare.moc" From 87349020a9ef7fc3b305b3381d9effb315737706 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sat, 29 Aug 2026 20:13:24 -0500 Subject: [PATCH 151/164] add icon to download button --- src/icons/download.svg | 5 ++ src/texturelab/assets.qrc | 1 + src/texturelab/launcher/launcherwindow.cpp | 13 ++++- src/texturelab/update/updatechecker.cpp | 60 +++++++++++----------- src/texturelab/update/updatechecker.h | 4 +- 5 files changed, 47 insertions(+), 36 deletions(-) create mode 100644 src/icons/download.svg diff --git a/src/icons/download.svg b/src/icons/download.svg new file mode 100644 index 00000000..0b25b2e5 --- /dev/null +++ b/src/icons/download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/texturelab/assets.qrc b/src/texturelab/assets.qrc index 43b09b0e..b193b4d0 100644 --- a/src/texturelab/assets.qrc +++ b/src/texturelab/assets.qrc @@ -97,6 +97,7 @@ ../icons/undo.svg ../icons/redo.svg ../icons/export.svg + ../icons/download.svg ../icons/chevron-down.svg ../icons/chevron-up.svg ../icons/logo.png diff --git a/src/texturelab/launcher/launcherwindow.cpp b/src/texturelab/launcher/launcherwindow.cpp index 0ec534cd..399f4b3c 100644 --- a/src/texturelab/launcher/launcherwindow.cpp +++ b/src/texturelab/launcher/launcherwindow.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -190,6 +191,11 @@ QWidget* LauncherWindow::buildTopBar() updateButton = new QToolButton(bar); updateButton->setObjectName(QLatin1String(kUpdateButtonName)); updateButton->setCursor(Qt::PointingHandCursor); + // The icon's stroke is white, which is what the accent-filled button wants; + // it is not recolored by the theme, so it must not be used on a light fill. + updateButton->setIcon(QIcon(QStringLiteral(":/icons/download.svg"))); + updateButton->setIconSize(QSize(14, 14)); + updateButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); updateButton->setVisible(false); layout->addWidget(updateButton); @@ -481,13 +487,16 @@ void LauncherWindow::showUpdateNotice(const QString& version, const QString& tit if (!updateButton) return; - updateButton->setText(tr("Update to %1").arg(version)); + // Fixed label rather than the version number: the button's job is to be + // recognisable at a glance, and the specific version is detail that belongs + // in the tooltip next to the release title. + updateButton->setText(tr("New Version Available")); QStringList tip; + tip << tr("Version %1").arg(version); if (!title.isEmpty()) tip << title; tip << downloadUrl; - tip << tr("via %1").arg(UpdateChecker::apiBase()); updateButton->setToolTip(tip.join(QLatin1Char('\n'))); updateButton->setVisible(true); diff --git a/src/texturelab/update/updatechecker.cpp b/src/texturelab/update/updatechecker.cpp index 9e2d0228..123536cf 100644 --- a/src/texturelab/update/updatechecker.cpp +++ b/src/texturelab/update/updatechecker.cpp @@ -21,7 +21,8 @@ namespace { // !!! Currently pointed at the local dev server. Set this back to // !!! "https://texturelab.io" before cutting a release: a shipped build pointed // !!! at localhost never reaches anything and silently reports no updates. -constexpr const char* kApiBase = "http://localhost:3333"; +// constexpr const char* kApiBase = "http://localhost:3333"; +constexpr const char* kApiBase = "https://v2.texturelab.io"; constexpr const char* kEnabledKey = "updateCheck"; @@ -67,30 +68,23 @@ void UpdateChecker::setEnabled(bool enabled) QString UpdateChecker::channel() { - const QString stored = appSettings().value(QStringLiteral("updateChannel")).toString(); + const QString stored = + appSettings().value(QStringLiteral("updateChannel")).toString(); if (stored == QLatin1String("stable") || stored == QLatin1String("beta")) return stored; // A pre-release tag on our own version means this is a beta build, and its // user is better served by beta releases than by being told nothing exists. - const QString self = appversion::normalize(QCoreApplication::applicationVersion()); - return self.contains(QLatin1Char('-')) ? QStringLiteral("beta") : QStringLiteral("stable"); -} - -QString UpdateChecker::platformKey() -{ -#if defined(Q_OS_WIN) - return QStringLiteral("windows"); -#elif defined(Q_OS_MACOS) - return QStringLiteral("mac"); -#else - return QStringLiteral("linux"); -#endif + const QString self = + appversion::normalize(QCoreApplication::applicationVersion()); + return self.contains(QLatin1Char('-')) ? QStringLiteral("beta") + : QStringLiteral("stable"); } QString UpdateChecker::knownUpdateVersion() { - const QString version = appSettings().value(QLatin1String(kKnownVersionKey)).toString(); + const QString version = + appSettings().value(QLatin1String(kKnownVersionKey)).toString(); if (version.isEmpty()) return QString(); @@ -129,8 +123,9 @@ void UpdateChecker::check(bool force) // Say what we already know before deciding whether to ask again. const QString known = knownUpdateVersion(); if (!known.isEmpty()) { - emit updateAvailable(known, settings.value(QLatin1String(kKnownTitleKey)).toString(), - settings.value(QLatin1String(kKnownUrlKey)).toString()); + emit updateAvailable( + known, settings.value(QLatin1String(kKnownTitleKey)).toString(), + settings.value(QLatin1String(kKnownUrlKey)).toString()); } // Once per run unless explicitly forced. @@ -144,7 +139,8 @@ void UpdateChecker::check(bool force) QNetworkRequest request(url); request.setHeader(QNetworkRequest::UserAgentHeader, - QStringLiteral("TextureLab/%1").arg(QCoreApplication::applicationVersion())); + QStringLiteral("TextureLab/%1") + .arg(QCoreApplication::applicationVersion())); request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); request.setTransferTimeout(8000); @@ -156,7 +152,8 @@ void UpdateChecker::check(bool force) checkedThisRun = true; QNetworkReply* reply = network->get(request); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { handleReply(reply); }); + connect(reply, &QNetworkReply::finished, this, + [this, reply]() { handleReply(reply); }); } void UpdateChecker::handleReply(QNetworkReply* reply) @@ -178,7 +175,8 @@ void UpdateChecker::handleReply(QNetworkReply* reply) QJsonParseError parseError; const QJsonDocument doc = QJsonDocument::fromJson(body, &parseError); if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { - emit checkFinished(false, QStringLiteral("Malformed response from the update server")); + emit checkFinished( + false, QStringLiteral("Malformed response from the update server")); return; } @@ -194,7 +192,8 @@ void UpdateChecker::handleReply(QNetworkReply* reply) const QJsonObject data = root.value(QStringLiteral("data")).toObject(); const QString version = data.value(QStringLiteral("version")).toString(); if (version.isEmpty()) { - emit checkFinished(false, QStringLiteral("Update server returned no version")); + emit checkFinished(false, + QStringLiteral("Update server returned no version")); return; } @@ -207,18 +206,17 @@ void UpdateChecker::handleReply(QNetworkReply* reply) return; } - const QJsonObject downloads = data.value(QStringLiteral("downloads")).toObject(); - QString downloadUrl = downloads.value(platformKey()).toString(); - - // No build for this platform yet — still worth telling them, pointed at the - // page rather than at nothing. - if (downloadUrl.isEmpty()) - downloadUrl = apiBase() + QStringLiteral("/#download"); + // Always the site's download page rather than a per-platform binary URL: + // it lists every build, so it stays correct when the server has no artifact + // for this platform yet, and the user lands somewhere that explains itself. + const QString downloadUrl = apiBase() + QStringLiteral("/download"); Telemetry::breadcrumb("update", "found " + version.toStdString()); - rememberUpdate(version, data.value(QStringLiteral("title")).toString(), downloadUrl); + rememberUpdate(version, data.value(QStringLiteral("title")).toString(), + downloadUrl); - emit updateAvailable(version, data.value(QStringLiteral("title")).toString(), downloadUrl); + emit updateAvailable( + version, data.value(QStringLiteral("title")).toString(), downloadUrl); emit checkFinished(true, QString()); } diff --git a/src/texturelab/update/updatechecker.h b/src/texturelab/update/updatechecker.h index e89c7bee..80367727 100644 --- a/src/texturelab/update/updatechecker.h +++ b/src/texturelab/update/updatechecker.h @@ -50,8 +50,7 @@ class UpdateChecker : public QObject { signals: // Emitted only when the published version is strictly newer than this one. - // `downloadUrl` is the build for this platform, falling back to the - // releases page when the server has no URL for it. + // `downloadUrl` is the site's /download page, which lists every build. void updateAvailable(const QString& version, const QString& title, const QString& downloadUrl); @@ -63,7 +62,6 @@ class UpdateChecker : public QObject { void handleReply(class QNetworkReply* reply); static void rememberUpdate(const QString& version, const QString& title, const QString& url); static void forgetUpdate(); - static QString platformKey(); QNetworkAccessManager* network = nullptr; bool inFlight = false; From 581638bc940e974e1bfe175accab67e48517a895 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 29 Aug 2026 21:34:36 -0500 Subject: [PATCH 152/164] fix sql dep on linux --- .github/workflows/build.yml | 10 ++++++++++ README.md | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d34bad68..5af85b07 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -125,6 +125,16 @@ jobs: export PATH=${{ github.workspace }}/Qt/Qt/6.7.3/gcc_64/bin:$PATH export LD_LIBRARY_PATH=${{ github.workspace }}/Qt/Qt/6.7.3/gcc_64/lib:$LD_LIBRARY_PATH + # linuxdeploy's qt plugin deploys *every* sqldriver plugin it finds and + # hard-fails when one has an unresolvable dependency. Qt ships + # libqsqlmimer.so, whose libmimerapi.so is a proprietary Mimer SQL + # client that isn't on the runner ("ERROR: Could not find dependency: + # libmimerapi.so"). We only ever open QSQLITE (src/catalog/database.cpp), + # so keep libqsqlite.so and drop the rest — that also stops us shipping + # the GPL libmysqlclient the MySQL driver drags in. + QT_SQLDRIVERS=${{ github.workspace }}/Qt/Qt/6.7.3/gcc_64/plugins/sqldrivers + find "$QT_SQLDRIVERS" -name 'libqsql*.so' ! -name 'libqsqlite.so' -delete + # Create desktop file cat > texturelab.desktop << 'EOF' [Desktop Entry] diff --git a/README.md b/README.md index eadaa1f7..71cd178b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,10 @@ install Qt 6 and required dependencies Note: Linux needs libmesa: https://doc.qt.io/qt-6/linux.html -sudo apt install build-essential libgl1-mesa-dev libxkbcommon-dev libvulkan-dev +sudo apt install build-essential libgl1-mesa-dev libxkbcommon-dev libvulkan-dev libcurl4-openssl-dev + +Note: libcurl4-openssl-dev is required by sentry-native (crash reporting). +Without it, CMake fails with "CURL: Required feature AsynchDNS is not found". ``` From fc66fd60e33760253aa949f5406de71da08e6bfa Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 29 Aug 2026 21:34:55 -0500 Subject: [PATCH 153/164] add empty state in launcher --- resources/qss/app.qss.in | 6 +++ src/texturelab/launcher/launcherwindow.cpp | 54 +++++++++++++++++++--- src/texturelab/launcher/launcherwindow.h | 7 +++ 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/resources/qss/app.qss.in b/resources/qss/app.qss.in index 1b8dee86..af1e658e 100644 --- a/resources/qss/app.qss.in +++ b/resources/qss/app.qss.in @@ -470,6 +470,12 @@ QPushButton[size="small"] { background: {{ctrl.hover}}; } +/* First-run overlay: the message, plus a "New Texture" button on the genuine + empty state. It floats over the grid, so it must not paint a panel of its + own. */ +#launcherEmptyPanel { + background: transparent; +} #launcherEmptyLabel { background: transparent; color: {{text.secondary}}; diff --git a/src/texturelab/launcher/launcherwindow.cpp b/src/texturelab/launcher/launcherwindow.cpp index 399f4b3c..a8aeba88 100644 --- a/src/texturelab/launcher/launcherwindow.cpp +++ b/src/texturelab/launcher/launcherwindow.cpp @@ -40,6 +40,7 @@ constexpr const char* kTopBarName = "launcherTopBar"; constexpr const char* kActionBarName = "launcherActionBar"; constexpr const char* kGridName = "launcherGrid"; constexpr const char* kFilterTabName = "launcherFilterTab"; +constexpr const char* kEmptyPanelName = "launcherEmptyPanel"; constexpr const char* kEmptyLabelName = "launcherEmptyLabel"; constexpr const char* kUpdateButtonName = "launcherUpdateButton"; @@ -97,11 +98,31 @@ LauncherWindow::LauncherWindow(QWidget* parent) : QWidget(parent) // Sits over the grid rather than replacing it, so switching filters can't // leave the window structurally empty. - emptyLabel = new QLabel(grid); + emptyPanel = new QWidget(grid); + emptyPanel->setObjectName(QLatin1String(kEmptyPanelName)); + emptyPanel->hide(); + + auto* emptyLayout = new QVBoxLayout(emptyPanel); + emptyLayout->setContentsMargins(24, 24, 24, 24); + emptyLayout->setSpacing(16); + emptyLayout->addStretch(1); + + emptyLabel = new QLabel(emptyPanel); emptyLabel->setObjectName(QLatin1String(kEmptyLabelName)); emptyLabel->setAlignment(Qt::AlignCenter); emptyLabel->setWordWrap(true); - emptyLabel->hide(); + emptyLayout->addWidget(emptyLabel); + + // The way out of a first-run window. Same signal as the action bar's + // button, so the caller can't tell which one the user pressed. + emptyNewButton = new QPushButton(tr("New Texture"), emptyPanel); + emptyNewButton->setProperty("variant", "primary"); // the one thing to do here + emptyNewButton->setCursor(Qt::PointingHandCursor); + connect(emptyNewButton, &QPushButton::clicked, this, &LauncherWindow::newTextureRequested); + emptyLayout->addWidget(emptyNewButton, 0, Qt::AlignHCenter); + emptyLayout->addStretch(1); + + grid->viewport()->installEventFilter(this); auto* layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); @@ -442,13 +463,14 @@ void LauncherWindow::refresh() void LauncherWindow::updateEmptyState() { if (model->totalCount() > 0) { - emptyLabel->hide(); + emptyPanel->hide(); return; } // Three different nothings, and conflating them is how a first-run window // ends up looking broken instead of new. QString message; + bool offerNew = false; if (!model->searchTerm().isEmpty()) { message = tr("No textures match “%1”").arg(model->searchTerm()); } @@ -460,13 +482,31 @@ void LauncherWindow::updateEmptyState() } else { message = tr("No textures yet.\n\nTextures you create or open will appear here."); + offerNew = true; } emptyLabel->setText(message); - emptyLabel->resize(grid->viewport()->size()); - emptyLabel->move(0, 0); - emptyLabel->show(); - emptyLabel->raise(); + // Only on the genuine first run — offering "New Texture" as the answer to a + // search that found nothing would be answering a different question. + emptyNewButton->setVisible(offerNew); + layoutEmptyPanel(); + emptyPanel->show(); + emptyPanel->raise(); +} + +void LauncherWindow::layoutEmptyPanel() +{ + if (emptyPanel) + emptyPanel->setGeometry(grid->viewport()->geometry()); +} + +bool LauncherWindow::eventFilter(QObject* watched, QEvent* event) +{ + // An overlay has to follow the viewport by hand; without this, resizing the + // window while empty leaves the button parked where the grid used to end. + if (watched == grid->viewport() && event->type() == QEvent::Resize) + layoutEmptyPanel(); + return QWidget::eventFilter(watched, event); } void LauncherWindow::showEvent(QShowEvent* event) diff --git a/src/texturelab/launcher/launcherwindow.h b/src/texturelab/launcher/launcherwindow.h index 94334cb7..cd2538ce 100644 --- a/src/texturelab/launcher/launcherwindow.h +++ b/src/texturelab/launcher/launcherwindow.h @@ -58,6 +58,7 @@ public slots: void dragEnterEvent(QDragEnterEvent* event) override; void dropEvent(QDropEvent* event) override; void showEvent(QShowEvent* event) override; + bool eventFilter(QObject* watched, QEvent* event) override; private: QWidget* buildTopBar(); @@ -67,6 +68,10 @@ public slots: void restoreViewState(); void saveViewState() const; void updateEmptyState(); + + // The empty-state overlay isn't a layout child, so it has to be re-fitted + // to the viewport by hand whenever the grid changes size. + void layoutEmptyPanel(); void openSelected(); // Asks the user where a missing texture went and re-points its index row, @@ -86,7 +91,9 @@ public slots: QLineEdit* search = nullptr; QComboBox* sortBox = nullptr; QSlider* sizeSlider = nullptr; + QWidget* emptyPanel = nullptr; QLabel* emptyLabel = nullptr; + QPushButton* emptyNewButton = nullptr; QPushButton* openButton = nullptr; QToolButton* updateButton = nullptr; QToolButton* gridToggle = nullptr; From 96beef4851add6b1461e0778c55947b97c6b7530 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 29 Aug 2026 23:58:35 -0500 Subject: [PATCH 154/164] add new batch of skies --- public/assets | 2 +- src/texturelab/assets.qrc | 20 +++++++++----------- src/texturelab/widgets/view3dwidget.cpp | 23 ++++++++++++----------- src/viewer3d/viewer3d.cpp | 2 +- 4 files changed, 23 insertions(+), 24 deletions(-) diff --git a/public/assets b/public/assets index f4592af3..3133e228 160000 --- a/public/assets +++ b/public/assets @@ -1 +1 @@ -Subproject commit f4592af3f371674831f2c86178f16f0cf431d2f9 +Subproject commit 3133e228b6a24d9e944c07899c9ecd446931f77a diff --git a/src/texturelab/assets.qrc b/src/texturelab/assets.qrc index b193b4d0..aa8b3be2 100644 --- a/src/texturelab/assets.qrc +++ b/src/texturelab/assets.qrc @@ -103,17 +103,15 @@ ../icons/logo.png - ../../public/assets/env/cave_wall/cave_wall_1k.hdr - ../../public/assets/env/christmas/christmas_photo_studio_01_1k.hdr - ../../public/assets/env/dresden_station_night/dresden_station_night_1k.hdr - ../../public/assets/env/hansaplatz/hansaplatz_1k.hdr - ../../public/assets/env/kloppenheim_05/kloppenheim_05_1k.hdr - ../../public/assets/env/modern_buildings_night/modern_buildings_night_1k.hdr - ../../public/assets/env/snowy_park_01/snowy_park_01_1k.hdr - ../../public/assets/env/spruit_sunrise/spruit_sunrise_1k.hdr - ../../public/assets/env/studio_small_07/studio_small_07_1k.hdr - ../../public/assets/env/wide_street_01_1k.hdr - ../../public/assets/env/sunny_rose_garden_1k.hdr + ../../public/assets/env/docklands_01/docklands_01_1k.hdr + ../../public/assets/env/golden_bay/golden_bay_1k.hdr + ../../public/assets/env/little_paris_eiffel_tower/little_paris_eiffel_tower_1k.hdr + ../../public/assets/env/sepulchral_chapel_basement/sepulchral_chapel_basement_1k.hdr + ../../public/assets/env/st_peters_square_night/st_peters_square_night_1k.hdr + ../../public/assets/env/stadium_01/stadium_01_1k.hdr + ../../public/assets/env/studio_kontrast_03/studio_kontrast_03_1k.hdr + ../../public/assets/env/university_workshop/university_workshop_1k.hdr + ../../public/assets/env/winter_river/winter_river_1k.hdr ../../public/assets/examples/Copper.texture diff --git a/src/texturelab/widgets/view3dwidget.cpp b/src/texturelab/widgets/view3dwidget.cpp index 5c84b389..fe6c602f 100644 --- a/src/texturelab/widgets/view3dwidget.cpp +++ b/src/texturelab/widgets/view3dwidget.cpp @@ -9,7 +9,7 @@ View3DWidget::View3DWidget() this->viewer = new Viewer3D(); this->setCentralWidget(viewer); - this->viewer->setDefaultEnvironment(":env/sunny_rose_garden_1k.hdr"); + this->viewer->setDefaultEnvironment(":env/studio_kontrast_03_1k.hdr"); // Create menu bar QMenuBar* menuBar = new QMenuBar(this); @@ -56,16 +56,17 @@ View3DWidget::View3DWidget() }; QVector envList = { - {"Cave Wall", ":env/cave_wall_1k.hdr"}, - {"Christmas", ":env/christmas_1k.hdr"}, - {"Dresden Station Night", ":env/dresden_station_night_1k.hdr"}, - {"Hansaplatz", ":env/hansaplatz_1k.hdr"}, - {"Kloppenheim 05", ":env/kloppenheim_05_1k.hdr"}, - {"Modern Buildings Night", ":env/modern_buildings_night_1k.hdr"}, - {"Snowy Park 01", ":env/snowy_park_01_1k.hdr"}, - {"Spruit Sunrise", ":env/spruit_sunrise_1k.hdr"}, - {"Studio Small 07", ":env/studio_small_07_1k.hdr"}, - {"Wide Street 01", ":env/wide_street_01_1k.hdr"}}; + {"Docklands 01", ":env/docklands_01_1k.hdr"}, + {"Golden Bay", ":env/golden_bay_1k.hdr"}, + {"Little Paris Eiffel Tower", + ":env/little_paris_eiffel_tower_1k.hdr"}, + {"Sepulchral Chapel Basement", + ":env/sepulchral_chapel_basement_1k.hdr"}, + {"St Peters Square Night", ":env/st_peters_square_night_1k.hdr"}, + {"Stadium 01", ":env/stadium_01_1k.hdr"}, + {"Studio Kontrast 03", ":env/studio_kontrast_03_1k.hdr"}, + {"University Workshop", ":env/university_workshop_1k.hdr"}, + {"Winter River", ":env/winter_river_1k.hdr"}}; for (const EnvInfo& env : envList) { QAction* envAction = envMenu->addAction(env.displayName); diff --git a/src/viewer3d/viewer3d.cpp b/src/viewer3d/viewer3d.cpp index 8e2457f7..89e4ad13 100644 --- a/src/viewer3d/viewer3d.cpp +++ b/src/viewer3d/viewer3d.cpp @@ -113,7 +113,7 @@ void Viewer3D::initializeGL() if (!defaultEnvPath.isEmpty()) renderer->loadEnvironment(defaultEnvPath); else - renderer->loadEnvironment(":env/sunny_rose_garden_1k.hdr"); + renderer->loadEnvironment(":env/studio_kontrast_03_1k.hdr"); } void Viewer3D::setDefaultEnvironment(const QString path) From 6160c922aca3252ca09f5b083eb41b41688faeb4 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 30 Aug 2026 11:27:51 -0500 Subject: [PATCH 155/164] make launch grid responsive --- src/texturelab/launcher/launcherwindow.cpp | 113 +++++++++++++++--- src/texturelab/launcher/launcherwindow.h | 16 +++ src/texturelab/launcher/texturecarddelegate.h | 9 +- 3 files changed, 120 insertions(+), 18 deletions(-) diff --git a/src/texturelab/launcher/launcherwindow.cpp b/src/texturelab/launcher/launcherwindow.cpp index a8aeba88..5c0b3583 100644 --- a/src/texturelab/launcher/launcherwindow.cpp +++ b/src/texturelab/launcher/launcherwindow.cpp @@ -25,9 +25,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -318,15 +320,14 @@ QWidget* LauncherWindow::buildActionBar() sizeSlider = new QSlider(Qt::Horizontal, bar); sizeSlider->setRange(TextureCardDelegate::MinCardWidth, TextureCardDelegate::MaxCardWidth); - sizeSlider->setValue(cardDelegate->cardWidth()); + sizeSlider->setValue(targetCardWidth); sizeSlider->setFixedWidth(120); + sizeSlider->setToolTip(tr("Card size — cards stretch to fill the row")); connect(sizeSlider, &QSlider::valueChanged, this, [this](int value) { - // setCardWidth emits sizeHintChanged; reset() forces the icon-mode - // layout to actually recompute positions rather than reflow within the - // old grid metrics. - cardDelegate->setCardWidth(value); - if (gridMode) - grid->reset(); + // A target, not the drawn width: relayoutGrid() turns it into a column + // count and hands the delegate whatever divides the viewport evenly. + targetCardWidth = value; + relayoutGrid(); saveViewState(); }); layout->addWidget(sizeSlider); @@ -364,6 +365,73 @@ void LauncherWindow::applySort(int comboIndex) } } +void LauncherWindow::relayoutGrid() +{ + if (!gridMode || !grid || !cardDelegate) + return; + + const int spacing = grid->spacing(); + + // grid->width(), not the viewport's: the viewport narrows when the + // scrollbar appears, and the scrollbar comes and goes as this function + // changes how many rows there are. Measuring the frame keeps the input to + // the calculation independent of its own output. + // + // The bar's width is then subtracted whether or not it is currently up. + // Icon mode lays out against the scrolling width regardless, so a row + // budgeted for the full viewport overruns by those few pixels and loses a + // whole column — and reserving it unconditionally is also what keeps the + // result from oscillating as the bar appears and disappears. + const int reserved = grid->style()->pixelMetric(QStyle::PM_ScrollBarExtent, nullptr, + grid->verticalScrollBar()); + const int available = grid->width() - grid->frameWidth() * 2 - reserved; + if (available <= 0) + return; + + // A grid cell is the card plus one spacing, and the row is indented by half + // of one before the first cell — the card sits centered in its cell, so the + // leading half-gutter is real estate the columns can't have. + const int usable = available - spacing / 2; + + // Columns from the target width, then the pitch widened to consume the + // remainder, so the leftover lands in the thumbnails instead of collecting + // as a ragged right margin. + // + // Stated as an explicit grid size rather than left to icon mode's own + // packing: with a grid size set the view fits exactly usable / pitch cells, + // which is a rule this can invert. Its default wrapping folds in the item + // margins and a fencepost, and being one pixel over there costs a whole + // column silently. + int columns = qMax(1, usable / (targetCardWidth + spacing)); + int width = usable / columns - spacing; + + // A maxed-out slider on a wide window would otherwise stretch past what the + // delegate is willing to draw, which puts the gap straight back. An extra + // column costs every card a few pixels and the row nothing. + while (width > TextureCardDelegate::MaxCardWidth) { + ++columns; + width = usable / columns - spacing; + } + + width = qMax(width, TextureCardDelegate::MinCardWidth); + + // The card width alone can't gate this: the first pass runs while the + // scrollbar is still up from a narrower state and lays out against that + // viewport, then the bar drops and the next pass computes the same width + // and would decline to re-fit the row it now has room for. + if (width == cardDelegate->cardWidth() && grid->viewport()->width() == laidOutWidth) + return; + + laidOutWidth = grid->viewport()->width(); + cardDelegate->setCardWidth(width); + + // setGridSize() relayouts on its own, but only when the value changes — the + // viewport-width case above arrives with the same grid size and still needs + // the row re-fitted. + grid->setGridSize(QSize(width + spacing, cardDelegate->heightForWidth(width) + spacing)); + grid->doItemsLayout(); +} + void LauncherWindow::setGridMode(bool useGrid) { gridMode = useGrid; @@ -380,8 +448,17 @@ void LauncherWindow::setGridMode(bool useGrid) // Rows are separated by their own hairline, so view spacing would only // break the continuous surface a table wants. grid->setSpacing(0); + + // Rows size themselves; leaving the card pitch in place would stamp + // every one of them into a square cell. + grid->setGridSize(QSize()); } + // The card width is a function of the viewport, and in list mode nothing + // has been maintaining it — recompute before the view asks for sizeHints. + laidOutWidth = -1; + relayoutGrid(); + // Icon mode caches item positions; swapping the delegate changes every // sizeHint, and only a reset makes the view ask again. grid->reset(); @@ -402,11 +479,11 @@ void LauncherWindow::restoreViewState() settings.beginGroup(QStringLiteral("launcher")); if (sizeSlider) { - const int width = settings.value(QStringLiteral("cardWidth"), - cardDelegate->cardWidth()).toInt(); - cardDelegate->setCardWidth(width); + const int width = settings.value(QStringLiteral("cardWidth"), targetCardWidth).toInt(); + targetCardWidth = qBound(TextureCardDelegate::MinCardWidth, width, + TextureCardDelegate::MaxCardWidth); QSignalBlocker block(sizeSlider); - sizeSlider->setValue(cardDelegate->cardWidth()); + sizeSlider->setValue(targetCardWidth); } if (sortBox) { @@ -439,8 +516,9 @@ void LauncherWindow::saveViewState() const QSettings settings; settings.beginGroup(QStringLiteral("launcher")); settings.setValue(QStringLiteral("gridMode"), gridMode); - if (cardDelegate) - settings.setValue(QStringLiteral("cardWidth"), cardDelegate->cardWidth()); + // The target, not the stretched result — restoring the latter would let a + // window resized once permanently redefine what the slider means. + settings.setValue(QStringLiteral("cardWidth"), targetCardWidth); if (sortBox) settings.setValue(QStringLiteral("sort"), sortBox->currentIndex()); @@ -502,10 +580,13 @@ void LauncherWindow::layoutEmptyPanel() bool LauncherWindow::eventFilter(QObject* watched, QEvent* event) { - // An overlay has to follow the viewport by hand; without this, resizing the - // window while empty leaves the button parked where the grid used to end. - if (watched == grid->viewport() && event->type() == QEvent::Resize) + if (watched == grid->viewport() && event->type() == QEvent::Resize) { + // An overlay has to follow the viewport by hand; without this, resizing + // the window while empty leaves the button parked where the grid used + // to end. layoutEmptyPanel(); + relayoutGrid(); + } return QWidget::eventFilter(watched, event); } diff --git a/src/texturelab/launcher/launcherwindow.h b/src/texturelab/launcher/launcherwindow.h index cd2538ce..09d0c0e1 100644 --- a/src/texturelab/launcher/launcherwindow.h +++ b/src/texturelab/launcher/launcherwindow.h @@ -65,6 +65,12 @@ public slots: QWidget* buildActionBar(); void applySort(int comboIndex); void setGridMode(bool grid); + + // Re-fits the cards to the viewport: columns come from the slider's target + // width, then every cell stretches to consume the remainder. IconMode's own + // layout keeps a fixed pitch and dumps the leftover as a ragged right + // margin, which is the gap this exists to close. + void relayoutGrid(); void restoreViewState(); void saveViewState() const; void updateEmptyState(); @@ -104,4 +110,14 @@ public slots: bool hasDocument = false; bool gridMode = true; + + // The viewport width the cards were last laid out against. The card width + // alone can't gate a relayout: the first pass can run while the scrollbar + // is still up from a narrower state, fit one column fewer than it computed, + // and then match on the next pass and decline to fix itself. + int laidOutWidth = -1; + + // What the slider asks for. The width the cards are actually drawn at is + // this one rounded to fill the row, and lives on the delegate. + int targetCardWidth = 160; }; diff --git a/src/texturelab/launcher/texturecarddelegate.h b/src/texturelab/launcher/texturecarddelegate.h index 82b9b2a9..8e6f1792 100644 --- a/src/texturelab/launcher/texturecarddelegate.h +++ b/src/texturelab/launcher/texturecarddelegate.h @@ -14,11 +14,16 @@ class TextureCardDelegate : public QStyledItemDelegate { public: explicit TextureCardDelegate(QObject* parent = nullptr); - // Card width in pixels; the thumbnail is square, so height follows. Driven - // by the action bar's size slider. + // Card width in pixels; the thumbnail is square, so height follows. Set by + // LauncherWindow::relayoutGrid(), which stretches the slider's target width + // to whatever divides the viewport evenly — not by the slider directly. void setCardWidth(int width); int cardWidth() const { return cardW; } + // The card height a given width implies. The grid needs it to state its + // cell size, which it has to do before handing the width over here. + int heightForWidth(int width) const { return width + textBlockHeight(); } + static constexpr int MinCardWidth = 96; static constexpr int MaxCardWidth = 256; From 0fe29c4e5ffd5ef14c546fa67584fd8b2c218311 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 30 Aug 2026 11:54:58 -0500 Subject: [PATCH 156/164] fix launcher layout gap --- src/texturelab/launcher/launcherwindow.cpp | 157 ++++++++++++++++----- src/texturelab/launcher/launcherwindow.h | 12 +- 2 files changed, 132 insertions(+), 37 deletions(-) diff --git a/src/texturelab/launcher/launcherwindow.cpp b/src/texturelab/launcher/launcherwindow.cpp index 5c0b3583..5fdcdf47 100644 --- a/src/texturelab/launcher/launcherwindow.cpp +++ b/src/texturelab/launcher/launcherwindow.cpp @@ -24,7 +24,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -54,6 +56,26 @@ bool isTextureFile(const QUrl& url) } // namespace +// Exists only to reach setViewportMargins(), which QAbstractScrollArea keeps +// protected. The grid uses it to park the pixels left over from dividing the +// row into whole columns, half at each end, instead of letting them all collect +// past the last card. +class LauncherGridView : public QListView { +public: + using QListView::QListView; + + void setLeadingMargin(int margin) + { + if (margin == leading) + return; + leading = margin; + setViewportMargins(margin, 0, 0, 0); + } + +private: + int leading = 0; +}; + LauncherWindow::LauncherWindow(QWidget* parent) : QWidget(parent) { setWindowTitle(QStringLiteral("TextureLab")); @@ -80,7 +102,7 @@ LauncherWindow::LauncherWindow(QWidget* parent) : QWidget(parent) // follows rather than polling. connect(&catalog, &CatalogService::catalogChanged, this, &LauncherWindow::refresh); - grid = new QListView(this); + grid = new LauncherGridView(this); grid->setObjectName(QLatin1String(kGridName)); grid->setModel(model); grid->setViewMode(QListView::IconMode); @@ -136,6 +158,12 @@ LauncherWindow::LauncherWindow(QWidget* parent) : QWidget(parent) connect(model, &QAbstractItemModel::modelReset, this, &LauncherWindow::updateEmptyState); connect(model, &QAbstractItemModel::rowsInserted, this, &LauncherWindow::updateEmptyState); + // Whether the grid scrolls decides whether it reserves room for a scrollbar, + // and that follows the row count, which moves without the window resizing. + connect(model, &QAbstractItemModel::modelReset, this, &LauncherWindow::relayoutGrid); + connect(model, &QAbstractItemModel::rowsInserted, this, &LauncherWindow::relayoutGrid); + connect(model, &QAbstractItemModel::rowsRemoved, this, &LauncherWindow::relayoutGrid); + // Last, so it can drive widgets the two build* methods created. restoreViewState(); updateEmptyState(); @@ -367,32 +395,24 @@ void LauncherWindow::applySort(int comboIndex) void LauncherWindow::relayoutGrid() { - if (!gridMode || !grid || !cardDelegate) + if (!gridMode || !grid || !cardDelegate || relayouting) return; + // Setting the viewport margins below resizes the viewport, and a viewport + // resize is what calls this — so the pass has to be allowed to finish + // before the one it provokes can start. + const QScopedValueRollback guard(relayouting, true); + const int spacing = grid->spacing(); // grid->width(), not the viewport's: the viewport narrows when the - // scrollbar appears, and the scrollbar comes and goes as this function - // changes how many rows there are. Measuring the frame keeps the input to - // the calculation independent of its own output. - // - // The bar's width is then subtracted whether or not it is currently up. - // Icon mode lays out against the scrolling width regardless, so a row - // budgeted for the full viewport overruns by those few pixels and loses a - // whole column — and reserving it unconditionally is also what keeps the - // result from oscillating as the bar appears and disappears. - const int reserved = grid->style()->pixelMetric(QStyle::PM_ScrollBarExtent, nullptr, - grid->verticalScrollBar()); - const int available = grid->width() - grid->frameWidth() * 2 - reserved; - if (available <= 0) + // scrollbar appears, and whether the scrollbar appears is one of the things + // this function decides. Measuring the frame keeps the input to the + // calculation independent of its own output. + const int total = grid->width() - grid->frameWidth() * 2; + if (total <= 0) return; - // A grid cell is the card plus one spacing, and the row is indented by half - // of one before the first cell — the card sits centered in its cell, so the - // leading half-gutter is real estate the columns can't have. - const int usable = available - spacing / 2; - // Columns from the target width, then the pitch widened to consume the // remainder, so the leftover lands in the thumbnails instead of collecting // as a ragged right margin. @@ -402,23 +422,86 @@ void LauncherWindow::relayoutGrid() // which is a rule this can invert. Its default wrapping folds in the item // margins and a fencepost, and being one pixel over there costs a whole // column silently. - int columns = qMax(1, usable / (targetCardWidth + spacing)); - int width = usable / columns - spacing; - - // A maxed-out slider on a wide window would otherwise stretch past what the - // delegate is willing to draw, which puts the gap straight back. An extra - // column costs every card a few pixels and the row nothing. - while (width > TextureCardDelegate::MaxCardWidth) { - ++columns; - width = usable / columns - spacing; - } + auto fit = [&](int available) { + // A grid cell is the card plus one spacing, and the row is indented by + // half of one before the first cell — the card sits centered in its + // cell, so the leading half-gutter is not real estate the columns can + // have. + const int usable = available - spacing / 2; + + int columns = qMax(1, usable / (targetCardWidth + spacing)); + int width = usable / columns - spacing; + + // A maxed-out slider on a wide window would otherwise stretch past what + // the delegate is willing to draw, which puts the gap straight back. An + // extra column costs every card a few pixels and the row nothing. + while (width > TextureCardDelegate::MaxCardWidth) { + ++columns; + width = usable / columns - spacing; + } - width = qMax(width, TextureCardDelegate::MinCardWidth); + return qMakePair(columns, qMax(width, TextureCardDelegate::MinCardWidth)); + }; - // The card width alone can't gate this: the first pass runs while the - // scrollbar is still up from a narrower state and lays out against that - // viewport, then the bar drops and the next pass computes the same width - // and would decline to re-fit the row it now has room for. + const int extent = grid->style()->pixelMetric(QStyle::PM_ScrollBarExtent, nullptr, + grid->verticalScrollBar()); + + // Lay the row out against the full width first, then ask whether that many + // rows overflow. Narrowing only ever means fewer columns and so more rows, + // so an answer of "it scrolls" can't be undone by the second pass — no + // oscillation between the two states. + QPair fitted = fit(total); + const int rows = (model->rowCount() + fitted.first - 1) / fitted.first; + const int contentHeight = + spacing / 2 + rows * (cardDelegate->heightForWidth(fitted.second) + spacing); + + // One row-gap of headroom before committing to no scrollbar: guessing wrong + // in that direction would leave the last row unreachable, where guessing + // wrong the other way only costs the strip of margin this is here to + // reclaim. + const bool scrolls = contentHeight > grid->viewport()->height() - spacing; + const int layoutWidth = scrolls ? total - extent : total; + if (scrolls) + fitted = fit(layoutWidth); + + // Icon mode deducts the scrollbar's width from every row whenever the + // policy is ScrollBarAsNeeded, whether or not the bar is actually up — that + // phantom reservation was the dead strip down the right-hand side. Ask for + // it only when the bar really is coming; when everything fits there is + // nothing to scroll and so nothing to reserve. + grid->setVerticalScrollBarPolicy(scrolls ? Qt::ScrollBarAsNeeded : Qt::ScrollBarAlwaysOff); + + + const int columns = fitted.first; + int width = fitted.second; + + // Dividing the row into whole columns leaves up to one pixel per column + // over, and it all collects past the last card — the asymmetry that reads + // as an odd right-hand margin. Split it instead, by indenting the leading + // edge. Measured against the frame width rather than the viewport's, so the + // margin this sets can't feed back into its own input. + int leftover = layoutWidth - spacing / 2 - columns * (width + spacing); + + // The row can't be centered on less slack than the half-gutter the wrap + // rule holds back at the far end. When the division came out nearly exact, + // giving up a pixel of card width buys that back at a pixel per column. + if (leftover < spacing / 2 && width > TextureCardDelegate::MinCardWidth) { + --width; + leftover += columns; + } + + // Never more than the slack itself: the columns were fitted to a row this + // wide, and indenting past what's spare would push the last one off it. + // Clamped before the split, because the pre-layout pass runs against a + // hundred-pixel window where the one column already overruns and the slack + // is negative. + const int slack = qMax(0, leftover); + grid->setLeadingMargin(qMin((slack + spacing / 2) / 2, slack)); + + // The card width alone can't gate this: a pass can run while the scrollbar + // is still up from a narrower state and lay out against that viewport, then + // the bar drops and the next pass computes the same width and would decline + // to re-fit the row it now has room for. if (width == cardDelegate->cardWidth() && grid->viewport()->width() == laidOutWidth) return; @@ -452,6 +535,10 @@ void LauncherWindow::setGridMode(bool useGrid) // Rows size themselves; leaving the card pitch in place would stamp // every one of them into a square cell. grid->setGridSize(QSize()); + + // Rows run the full width of the window; the centering margin is a + // property of the card row, not of the view. + grid->setLeadingMargin(0); } // The card width is a function of the viewport, and in list mode nothing diff --git a/src/texturelab/launcher/launcherwindow.h b/src/texturelab/launcher/launcherwindow.h index 09d0c0e1..9438732d 100644 --- a/src/texturelab/launcher/launcherwindow.h +++ b/src/texturelab/launcher/launcherwindow.h @@ -7,11 +7,14 @@ class QComboBox; class QLabel; class QLineEdit; -class QListView; class QPushButton; class QSlider; class QToolButton; +// Defined in launcherwindow.cpp: a QListView that will let this window set the +// viewport margins, which QAbstractScrollArea otherwise keeps protected. +class LauncherGridView; + class TextureCardDelegate; class TextureListModel; class TextureRowDelegate; @@ -93,7 +96,7 @@ public slots: TextureCardDelegate* cardDelegate = nullptr; TextureRowDelegate* rowDelegate = nullptr; - QListView* grid = nullptr; + LauncherGridView* grid = nullptr; QLineEdit* search = nullptr; QComboBox* sortBox = nullptr; QSlider* sizeSlider = nullptr; @@ -111,6 +114,11 @@ public slots: bool hasDocument = false; bool gridMode = true; + // Guards against the re-entrant relayout that setting the viewport margins + // provokes: the margin resizes the viewport, and the viewport's resize is + // what calls this in the first place. + bool relayouting = false; + // The viewport width the cards were last laid out against. The card width // alone can't gate a relayout: the first pass can run while the scrollbar // is still up from a narrower state, fit one column fewer than it computed, From 1987565bda258296a1e07596d2cb07ac7ee33992 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 30 Aug 2026 12:21:59 -0500 Subject: [PATCH 157/164] add crash consent dialog --- resources/qss/app.qss.in | 10 ++ src/texturelab/CMakeLists.txt | 2 + src/texturelab/launcher/launcherwindow.cpp | 49 ++++++ src/texturelab/launcher/launcherwindow.h | 10 ++ src/texturelab/main.cpp | 10 +- src/texturelab/mainwindow.cpp | 18 ++- src/texturelab/telemetry.cpp | 58 +++++++ src/texturelab/telemetry.h | 20 +++ src/texturelab/widgets/crashconsentdialog.cpp | 145 ++++++++++++++++++ src/texturelab/widgets/crashconsentdialog.h | 29 ++++ 10 files changed, 340 insertions(+), 11 deletions(-) create mode 100644 src/texturelab/widgets/crashconsentdialog.cpp create mode 100644 src/texturelab/widgets/crashconsentdialog.h diff --git a/resources/qss/app.qss.in b/resources/qss/app.qss.in index af1e658e..eebe4d2f 100644 --- a/resources/qss/app.qss.in +++ b/resources/qss/app.qss.in @@ -384,6 +384,16 @@ QPushButton[size="small"] { #ExportDestination[empty="true"] { color: {{text.disabled}}; } #ExportHelp { color: {{text.disabled}}; } +/* Crash-report consent dialog. Reads as one block of prose, so the only + hierarchy is the title's size and the way the secondary text steps back. */ +#ConsentDialog { background: {{bg.window}}; } +#ConsentTitle { color: {{text.primary}}; font-size: 17px; font-weight: bold; } +#ConsentBody { color: {{text.secondary}}; font-size: 13px; } +#ConsentFactLead { color: {{text.primary}}; font-size: 12px; font-weight: bold; } +#ConsentFact { color: {{text.secondary}}; font-size: 12px; } +#ConsentNote { color: {{text.disabled}}; font-size: 11px; } +#ConsentSeparator { color: {{border.subtle}}; } + /* About dialog typography */ #AboutTitle { color: {{text.primary}}; font-size: 26px; font-weight: bold; } #AboutTag { color: {{text.secondary}}; font-size: 12px; } diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 7d0eef2e..a40f3e13 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -243,6 +243,8 @@ set(PROJECT_SOURCES ./undo/texturechannelassigncommand.cpp ./widgets/aboutdialog.h ./widgets/aboutdialog.cpp + ./widgets/crashconsentdialog.h + ./widgets/crashconsentdialog.cpp ./widgets/exportdialog.h ./widgets/exportdialog.cpp ./graphics/texturerenderer.h diff --git a/src/texturelab/launcher/launcherwindow.cpp b/src/texturelab/launcher/launcherwindow.cpp index 5fdcdf47..9fb90649 100644 --- a/src/texturelab/launcher/launcherwindow.cpp +++ b/src/texturelab/launcher/launcherwindow.cpp @@ -4,8 +4,10 @@ #include "libraries/libversion.h" #include "texturecarddelegate.h" #include "texturelistmodel.h" +#include "telemetry.h" #include "texturerowdelegate.h" #include "update/updatechecker.h" +#include "widgets/crashconsentdialog.h" #include #include @@ -32,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -48,6 +51,12 @@ constexpr const char* kEmptyPanelName = "launcherEmptyPanel"; constexpr const char* kEmptyLabelName = "launcherEmptyLabel"; constexpr const char* kUpdateButtonName = "launcherUpdateButton"; +// Long enough for the window manager to have mapped and placed the launcher, +// which is what the prompt centres itself on. Nothing breaks if it is early — +// the dialog just opens off-centre — so this buys margin rather than being a +// timing the code depends on. +constexpr int kConsentPromptDelayMs = 250; + bool isTextureFile(const QUrl& url) { return url.isLocalFile() && url.toLocalFile().endsWith(QStringLiteral(".texture"), @@ -299,6 +308,25 @@ QWidget* LauncherWindow::buildTopBar() toggleChecks->setChecked(UpdateChecker::isEnabled()); connect(toggleChecks, &QAction::toggled, this, [](bool on) { UpdateChecker::setEnabled(on); }); + menu->addSeparator(); + + crashReportsAction = menu->addAction(tr("Send Anonymous Crash Reports")); + crashReportsAction->setCheckable(true); + crashReportsAction->setChecked(Telemetry::isAllowed()); + connect(crashReportsAction, &QAction::toggled, this, [](bool on) { + // Toggling is an answer too, so it stamps the version and stops the + // prompt coming back for a decision the user has just made by hand. + Telemetry::recordConsent(on); + Telemetry::setEnabled(on); + }); + + // The editor's Help menu writes the same setting, so the check can be stale + // by the time the menu is opened. + connect(menu, &QMenu::aboutToShow, this, [this]() { + QSignalBlocker block(crashReportsAction); + crashReportsAction->setChecked(Telemetry::isAllowed()); + }); + menu->addSeparator(); menu->addAction(QStringLiteral("Clear Missing Textures"), this, [this]() { CatalogService& catalog = CatalogService::instance(); @@ -687,6 +715,27 @@ void LauncherWindow::showEvent(QShowEvent* event) // day costs one request every few hours. if (updates) updates->check(); + + maybeAskCrashConsent(); +} + +void LauncherWindow::maybeAskCrashConsent() +{ + if (consentPrompted || !Telemetry::consentNeeded()) + return; + + consentPrompted = true; + + // Deferred so the launcher is painted, and placed by the window manager, + // before the prompt measures it to centre itself. Asked over a blank + // window, the prompt reads like an installer step rather than something the + // app is asking for. + QTimer::singleShot(kConsentPromptDelayMs, this, [this]() { + if (Telemetry::consentNeeded()) + CrashConsentDialog::ask(this); + if (crashReportsAction) + crashReportsAction->setChecked(Telemetry::isAllowed()); + }); } void LauncherWindow::showUpdateNotice(const QString& version, const QString& title, diff --git a/src/texturelab/launcher/launcherwindow.h b/src/texturelab/launcher/launcherwindow.h index 9438732d..448e281b 100644 --- a/src/texturelab/launcher/launcherwindow.h +++ b/src/texturelab/launcher/launcherwindow.h @@ -4,6 +4,7 @@ #include +class QAction; class QComboBox; class QLabel; class QLineEdit; @@ -87,6 +88,10 @@ public slots: // keeping stars, tags, and recency. void locate(const catalog::TextureRecord& rec); + // Puts the crash-report consent prompt up the first time the launcher is + // shown on a version that hasn't been asked about yet. + void maybeAskCrashConsent(); + void showContextMenu(const QPoint& pos); void toggleStarOnSelection(); void removeSelectionFromLauncher(); @@ -105,6 +110,7 @@ public slots: QPushButton* emptyNewButton = nullptr; QPushButton* openButton = nullptr; QToolButton* updateButton = nullptr; + QAction* crashReportsAction = nullptr; QToolButton* gridToggle = nullptr; QToolButton* listToggle = nullptr; QToolButton* allTab = nullptr; @@ -114,6 +120,10 @@ public slots: bool hasDocument = false; bool gridMode = true; + // The prompt is deferred a tick so the launcher paints behind it, which + // leaves a window where a second showEvent could queue a second copy. + bool consentPrompted = false; + // Guards against the re-entrant relayout that setting the viewport margins // provokes: the margin resizes the viewport, and the viewport's resize is // what calls this in the first place. diff --git a/src/texturelab/main.cpp b/src/texturelab/main.cpp index d8a68e27..78847957 100644 --- a/src/texturelab/main.cpp +++ b/src/texturelab/main.cpp @@ -5,7 +5,6 @@ #include "version.h" #include -#include #include #include @@ -76,11 +75,10 @@ static void applyDarkTheme(QApplication& app) int main(int argc, char* argv[]) { - // Read opt-out before constructing QApplication so we can use QSettings - // with an explicit scope (no org/app name set yet). - QSettings settings(QSettings::UserScope, "texturelab", "texturelab"); - bool crashReportingEnabled = - settings.value("crashReporting", true).toBool(); + // Read the stored answer before constructing QApplication. Off until the + // consent prompt has actually been answered — the launcher puts it up on + // first run and turns collection on from there if the answer is yes. + const bool crashReportingEnabled = Telemetry::isAllowed(); // Init Sentry before QApplication; resolves paths via Qt helpers after // QCoreApplication is available (handler_path needs applicationDirPath). diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 63d436aa..8c26cd9c 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -568,12 +568,20 @@ void MainWindow::setupMenus() auto crashReportingAction = optionsMenu->addAction("Send Anonymous Crash Reports"); crashReportingAction->setCheckable(true); - QSettings settings(QSettings::UserScope, "texturelab", "texturelab"); - crashReportingAction->setChecked( - settings.value("crashReporting", true).toBool()); + crashReportingAction->setChecked(Telemetry::isAllowed()); connect(crashReportingAction, &QAction::toggled, [](bool checked) { - QSettings s(QSettings::UserScope, "texturelab", "texturelab"); - s.setValue("crashReporting", checked); + // Through Telemetry rather than straight to QSettings, so the change + // takes effect now instead of on the next launch — and so toggling it + // by hand counts as having answered the consent prompt. + Telemetry::recordConsent(checked); + Telemetry::setEnabled(checked); + }); + + // The launcher's gear menu writes the same setting, so the check can be + // stale by the time this menu is opened. + connect(optionsMenu, &QMenu::aboutToShow, this, [crashReportingAction]() { + QSignalBlocker block(crashReportingAction); + crashReportingAction->setChecked(Telemetry::isAllowed()); }); } diff --git a/src/texturelab/telemetry.cpp b/src/texturelab/telemetry.cpp index 26b91ccd..0bba7d16 100644 --- a/src/texturelab/telemetry.cpp +++ b/src/texturelab/telemetry.cpp @@ -5,10 +5,27 @@ #include #include +#include #include +#include static bool g_enabled = false; +namespace { + +// Explicit scope: init() runs before the organization and application names are +// set on QApplication, so the default constructor would read the wrong file. +QSettings consentSettings() +{ + return QSettings(QSettings::UserScope, QStringLiteral("texturelab"), + QStringLiteral("texturelab")); +} + +constexpr const char* kAllowedKey = "crashReporting"; +constexpr const char* kAskedVersionKey = "crashReportingConsentVersion"; + +} // namespace + void Telemetry::init(bool enabled) { // TEXTURELAB_SENTRY_DSN is injected at compile time from CMake. @@ -52,6 +69,47 @@ void Telemetry::close() sentry_close(); } +void Telemetry::setEnabled(bool enabled) +{ + if (enabled == g_enabled) + return; + + if (enabled) { + init(true); + } + else { + sentry_close(); + g_enabled = false; + } +} + +bool Telemetry::isEnabled() +{ + return g_enabled; +} + +bool Telemetry::isAllowed() +{ + // Defaults to off: an install that has never answered the prompt has not + // agreed to anything, and a crash before the first answer is the one case + // where staying quiet costs the least. + return consentSettings().value(QLatin1String(kAllowedKey), false).toBool(); +} + +bool Telemetry::consentNeeded() +{ + const QString asked = + consentSettings().value(QLatin1String(kAskedVersionKey)).toString(); + return asked != QLatin1String(TEXTURELAB_VERSION); +} + +void Telemetry::recordConsent(bool allowed) +{ + QSettings settings = consentSettings(); + settings.setValue(QLatin1String(kAllowedKey), allowed); + settings.setValue(QLatin1String(kAskedVersionKey), QLatin1String(TEXTURELAB_VERSION)); +} + void Telemetry::breadcrumb(const char* category, const std::string& message) { if (!g_enabled) diff --git a/src/texturelab/telemetry.h b/src/texturelab/telemetry.h index 1846150c..ca9f35b9 100644 --- a/src/texturelab/telemetry.h +++ b/src/texturelab/telemetry.h @@ -10,6 +10,26 @@ void init(bool enabled); // Call after a.exec() returns to flush queued events. void close(); +// Turns collection on or off after startup, which is what the consent prompt +// needs: answering it has to take effect now, not on the next launch. +void setEnabled(bool enabled); + +// Whether crash reporting is currently running. +bool isEnabled(); + +// The user's stored answer. Off until they have actually been asked — the +// absence of an answer is not consent. +bool isAllowed(); + +// True when the consent prompt is owed. Asked once per released version, so a +// build that changes what gets collected gets a fresh answer rather than +// inheriting one given about an older one. The build hash is deliberately not +// part of the comparison, or every dev build would re-ask. +bool consentNeeded(); + +// Stores the answer, and stamps the version it was given about. +void recordConsent(bool allowed); + // Add a breadcrumb (category + message) to the current session context. void breadcrumb(const char* category, const std::string& message); diff --git a/src/texturelab/widgets/crashconsentdialog.cpp b/src/texturelab/widgets/crashconsentdialog.cpp new file mode 100644 index 00000000..267ee48d --- /dev/null +++ b/src/texturelab/widgets/crashconsentdialog.cpp @@ -0,0 +1,145 @@ +#include "crashconsentdialog.h" + +#include "telemetry.h" + +#include +#include +#include +#include +#include + +namespace { + +// Wide enough that the two fact lines wrap at most once, which is what keeps +// the block reading as a list rather than a paragraph. +constexpr int kDialogWidth = 460; +constexpr int kMargin = 28; +constexpr int kLeadColumn = 72; + +} // namespace + +CrashConsentDialog::CrashConsentDialog(QWidget* parent) : QDialog(parent) +{ + setWindowTitle(tr("Crash Reports")); + setObjectName(QStringLiteral("ConsentDialog")); + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + setFixedWidth(kDialogWidth); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(kMargin, kMargin - 4, kMargin, kMargin - 6); + layout->setSpacing(14); + + auto* title = new QLabel(tr("Help fix crashes"), this); + title->setObjectName(QStringLiteral("ConsentTitle")); + layout->addWidget(title); + + auto* body = new QLabel( + tr("If TextureLab crashes, it can send a report so the bug can be found and fixed. " + "Nothing is sent unless it crashes."), + this); + body->setObjectName(QStringLiteral("ConsentBody")); + body->setWordWrap(true); + layout->addWidget(body); + + auto* rule = new QFrame(this); + rule->setObjectName(QStringLiteral("ConsentSeparator")); + rule->setFrameShape(QFrame::HLine); + layout->addWidget(rule); + + // The two halves of the answer to "what are you actually sending?", which + // is the question the prompt exists to answer. + auto* facts = new QVBoxLayout(); + facts->setSpacing(8); + facts->addWidget(buildFact( + tr("Sent"), + tr("Where in the code the crash happened, TextureLab's version, and your " + "operating system."))); + facts->addWidget(buildFact( + tr("Not sent"), + tr("Your textures. No project files are uploaded, and there is no account or " + "sign-in involved."))); + layout->addLayout(facts); + + auto* provider = new QLabel( + tr("Reports are handled by Sentry, a crash-reporting service. You can change this " + "any time from the ⚙ menu."), + this); + provider->setObjectName(QStringLiteral("ConsentNote")); + provider->setWordWrap(true); + layout->addWidget(provider); + + layout->addSpacing(2); + + auto* buttons = new QHBoxLayout(); + buttons->setSpacing(8); + buttons->addStretch(1); + + auto* decline = new QPushButton(tr("Not now"), this); + decline->setCursor(Qt::PointingHandCursor); + connect(decline, &QPushButton::clicked, this, &QDialog::reject); + buttons->addWidget(decline); + + auto* accept = new QPushButton(tr("Send crash reports"), this); + accept->setProperty("variant", "primary"); + accept->setCursor(Qt::PointingHandCursor); + accept->setDefault(true); + connect(accept, &QPushButton::clicked, this, &QDialog::accept); + buttons->addWidget(accept); + + layout->addLayout(buttons); + + // Word-wrapped labels only know their height once they know their width, so + // the height has to be pinned after the fixed width is in effect. + layout->activate(); + setFixedHeight(sizeHint().height()); +} + +void CrashConsentDialog::showEvent(QShowEvent* event) +{ + QDialog::showEvent(event); + + // Centred by hand rather than left to QDialog. Its own placement runs off + // the parent's geometry at construction, and on a multi-monitor X11 desktop + // the launcher has not been placed by the window manager that early — the + // prompt then lands centred on where the launcher was going to be, which + // can be a different screen from where it ended up. + if (const QWidget* owner = parentWidget() ? parentWidget()->window() : nullptr) { + const QRect area = owner->frameGeometry(); + move(area.center() - QPoint(width() / 2, height() / 2)); + } +} + +QWidget* CrashConsentDialog::buildFact(const QString& lead, const QString& detail) +{ + auto* row = new QWidget(this); + + auto* layout = new QHBoxLayout(row); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(12); + + auto* leadLabel = new QLabel(lead, row); + leadLabel->setObjectName(QStringLiteral("ConsentFactLead")); + leadLabel->setFixedWidth(kLeadColumn); + // Top-aligned so a lead word stays level with the first line of a detail + // that wraps, rather than drifting to the middle of the block. + leadLabel->setAlignment(Qt::AlignLeft | Qt::AlignTop); + layout->addWidget(leadLabel); + + auto* detailLabel = new QLabel(detail, row); + detailLabel->setObjectName(QStringLiteral("ConsentFact")); + detailLabel->setWordWrap(true); + detailLabel->setAlignment(Qt::AlignLeft | Qt::AlignTop); + layout->addWidget(detailLabel, 1); + + return row; +} + +bool CrashConsentDialog::ask(QWidget* parent) +{ + CrashConsentDialog dialog(parent); + const bool allowed = dialog.exec() == QDialog::Accepted; + + Telemetry::recordConsent(allowed); + Telemetry::setEnabled(allowed); + return allowed; +} diff --git a/src/texturelab/widgets/crashconsentdialog.h b/src/texturelab/widgets/crashconsentdialog.h new file mode 100644 index 00000000..4e720c4c --- /dev/null +++ b/src/texturelab/widgets/crashconsentdialog.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +// Asks permission to send crash reports, once per released version. +// +// Says what leaves the machine and what doesn't, because "help improve the app" +// on its own asks the user to agree to something they can't see. All styling +// comes from the theme (see #Consent* in app.qss.in) — no inline colors here. +class CrashConsentDialog : public QDialog { + Q_OBJECT + +public: + explicit CrashConsentDialog(QWidget* parent = nullptr); + + // Puts the prompt up and records the answer, turning collection on or off + // to match. Returns what the user chose. + // + // Dismissing the dialog counts as declining but still counts as asked: a + // prompt that reappears every launch until it gets the answer it wants is + // not a question. + static bool ask(QWidget* parent); + +protected: + void showEvent(QShowEvent* event) override; + +private: + QWidget* buildFact(const QString& lead, const QString& detail); +}; From 113adfc037a4eae3397ba09d04199a1c92de6ac0 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 30 Aug 2026 12:39:45 -0500 Subject: [PATCH 158/164] add rotation to env - choose brightest side as default in view --- src/texturelab/widgets/view3dwidget.cpp | 82 ++++++++++++++++++------- src/viewer3d/assets/skybox.vert | 5 +- src/viewer3d/renderer/renderer.cpp | 28 +++++++-- src/viewer3d/renderer/renderer.h | 11 +++- src/viewer3d/viewer3d.cpp | 24 ++++++-- src/viewer3d/viewer3d.h | 8 ++- 6 files changed, 122 insertions(+), 36 deletions(-) diff --git a/src/texturelab/widgets/view3dwidget.cpp b/src/texturelab/widgets/view3dwidget.cpp index fe6c602f..7e91228b 100644 --- a/src/texturelab/widgets/view3dwidget.cpp +++ b/src/texturelab/widgets/view3dwidget.cpp @@ -3,13 +3,66 @@ #include #include #include +#include +#include + +namespace { + +// Available HDR environments. +// +// `rotation` is a yaw in degrees about the up axis, applied to both the skybox +// and the IBL lookups (Renderer::envRotation). Several of these HDRIs point +// their darkest quarter at the default camera, which sits on -Z, so the model +// opened as a silhouette. Each value was picked by maximising the diffuse +// irradiance on a normal 30 degrees off the camera-facing one, which lands the +// sky's brightest arc behind and to the left of the camera as a key light. +struct EnvInfo { + QString displayName; + QString resourcePath; + float rotation; +}; + +const QVector& environments() +{ + static const QVector envList = { + {"Docklands 01", ":env/docklands_01_1k.hdr", -100.0f}, + {"Golden Bay", ":env/golden_bay_1k.hdr", -85.0f}, + {"Little Paris Eiffel Tower", ":env/little_paris_eiffel_tower_1k.hdr", + -95.0f}, + {"Sepulchral Chapel Basement", + ":env/sepulchral_chapel_basement_1k.hdr", 25.0f}, + {"St Peters Square Night", ":env/st_peters_square_night_1k.hdr", + -95.0f}, + {"Stadium 01", ":env/stadium_01_1k.hdr", -120.0f}, + {"Studio Kontrast 03", ":env/studio_kontrast_03_1k.hdr", 160.0f}, + {"University Workshop", ":env/university_workshop_1k.hdr", 140.0f}, + {"Winter River", ":env/winter_river_1k.hdr", -100.0f}}; + return envList; +} + +// The env the viewer opens on. Kept in sync with kFallbackEnvPath/Rotation in +// viewer3d.cpp, which covers hosts that never call setDefaultEnvironment(). +const EnvInfo& defaultEnvironment() +{ + const QString defaultPath = + QStringLiteral(":env/studio_kontrast_03_1k.hdr"); + for (const EnvInfo& env : environments()) { + if (env.resourcePath == defaultPath) + return env; + } + return environments().first(); +} + +} // namespace View3DWidget::View3DWidget() { this->viewer = new Viewer3D(); this->setCentralWidget(viewer); - this->viewer->setDefaultEnvironment(":env/studio_kontrast_03_1k.hdr"); + const EnvInfo& defaultEnv = defaultEnvironment(); + this->viewer->setDefaultEnvironment(defaultEnv.resourcePath, + defaultEnv.rotation); // Create menu bar QMenuBar* menuBar = new QMenuBar(this); @@ -49,30 +102,13 @@ View3DWidget::View3DWidget() // Environment menu QMenu* envMenu = menuBar->addMenu("Environment"); - // List of available HDR environments - struct EnvInfo { - QString displayName; - QString resourcePath; - }; - - QVector envList = { - {"Docklands 01", ":env/docklands_01_1k.hdr"}, - {"Golden Bay", ":env/golden_bay_1k.hdr"}, - {"Little Paris Eiffel Tower", - ":env/little_paris_eiffel_tower_1k.hdr"}, - {"Sepulchral Chapel Basement", - ":env/sepulchral_chapel_basement_1k.hdr"}, - {"St Peters Square Night", ":env/st_peters_square_night_1k.hdr"}, - {"Stadium 01", ":env/stadium_01_1k.hdr"}, - {"Studio Kontrast 03", ":env/studio_kontrast_03_1k.hdr"}, - {"University Workshop", ":env/university_workshop_1k.hdr"}, - {"Winter River", ":env/winter_river_1k.hdr"}}; - - for (const EnvInfo& env : envList) { + for (const EnvInfo& env : environments()) { QAction* envAction = envMenu->addAction(env.displayName); QString path = env.resourcePath; - connect(envAction, &QAction::triggered, - [this, path]() { this->viewer->loadEnvironment(path); }); + float rotation = env.rotation; + connect(envAction, &QAction::triggered, [this, path, rotation]() { + this->viewer->loadEnvironment(path, rotation); + }); } } diff --git a/src/viewer3d/assets/skybox.vert b/src/viewer3d/assets/skybox.vert index 9b467787..e63b48ee 100644 --- a/src/viewer3d/assets/skybox.vert +++ b/src/viewer3d/assets/skybox.vert @@ -7,10 +7,13 @@ out vec3 v_texCoord; uniform mat4 u_modelMatrix; uniform mat4 u_viewMatrix; uniform mat4 u_projectionMatrix; +// Yaw about the up axis, matching u_EnvRotation in the pbr shader so the +// background and the image based lighting stay in sync. +uniform mat3 u_envRotation; void main() { - v_texCoord = a_position; + v_texCoord = u_envRotation * a_position; // Remove translation from view matrix mat4 rotView = mat4(mat3(u_viewMatrix)); diff --git a/src/viewer3d/renderer/renderer.cpp b/src/viewer3d/renderer/renderer.cpp index df0a5338..ff9b282b 100644 --- a/src/viewer3d/renderer/renderer.cpp +++ b/src/viewer3d/renderer/renderer.cpp @@ -4,6 +4,8 @@ #include "../shadercache.h" #include +#include +#include #include #include @@ -68,12 +70,31 @@ void Renderer::init(QOpenGLFunctions* gl) iblSampler->gl = gl; } -void Renderer::loadEnvironment(const QString& path) +void Renderer::loadEnvironment(const QString& path, float rotationDegrees) { + this->envRotation = rotationDegrees; iblSampler->init(path); iblSampler->filterAll(); } +void Renderer::setEnvironmentRotation(float degrees) +{ + this->envRotation = degrees; +} + +QMatrix3x3 Renderer::envRotationMatrix() const +{ + const float rad = qDegreesToRadians(envRotation); + const float c = std::cos(rad); + const float s = std::sin(rad); + + // Row-major yaw about the up (Y) axis. The shaders apply it to the lookup + // direction, which turns the environment itself by the same angle: content + // sitting at azimuth a ends up seen at azimuth a + envRotation. + const float values[9] = {c, 0.0f, s, 0.0f, 1.0f, 0.0f, -s, 0.0f, c}; + return QMatrix3x3(values); +} + void Renderer::renderMesh(Mesh* mesh, Material* material) {} void Renderer::updateMaterial(Material* material) @@ -395,9 +416,7 @@ void Renderer::renderGltfMesh(Mesh* mesh, Material* material, shader->setUniformValue("u_MipCount", iblSampler->mipmapLevels); - QMatrix3x3 envRot; - envRot.setToIdentity(); - shader->setUniformValue("u_EnvRotation", envRot); + shader->setUniformValue("u_EnvRotation", envRotationMatrix()); shader->setUniformValue("u_EnvIntensity", 1.0f); // Setup punctual lights (matches Three.js setupLighting) - conditional @@ -533,6 +552,7 @@ void Renderer::renderSkybox(Mesh* mesh, const QMatrix4x4& viewMatrix, skyboxShader->setUniformValue("u_modelMatrix", modelMatrix); skyboxShader->setUniformValue("u_viewMatrix", viewMatrix); skyboxShader->setUniformValue("u_projectionMatrix", projMatrix); + skyboxShader->setUniformValue("u_envRotation", envRotationMatrix()); // Bind environment cubemap gl->glActiveTexture(GL_TEXTURE0); diff --git a/src/viewer3d/renderer/renderer.h b/src/viewer3d/renderer/renderer.h index b9955f91..6ea6430b 100644 --- a/src/viewer3d/renderer/renderer.h +++ b/src/viewer3d/renderer/renderer.h @@ -104,8 +104,17 @@ class Renderer { QOpenGLShaderProgram* skyboxShader = nullptr; bool usePunctualLights = true; // Toggle punctual lighting (Three.js style) + // Yaw applied to the environment about the up axis, in degrees. Several + // HDRIs face their darkest quarter at the default camera, so each sky + // carries a rotation that turns its bright side towards the viewer. + float envRotation = 0.0f; + void init(QOpenGLFunctions* gl); - void loadEnvironment(const QString& path); + void loadEnvironment(const QString& path, float rotationDegrees = 0.0f); + void setEnvironmentRotation(float degrees); + // Yaw matrix handed to both the skybox and the IBL lookups so the + // background and the lighting always agree. + QMatrix3x3 envRotationMatrix() const; void renderMesh(Mesh* mesh, Material* material); void updateMaterial(Material* material); diff --git a/src/viewer3d/viewer3d.cpp b/src/viewer3d/viewer3d.cpp index 89e4ad13..b089273b 100644 --- a/src/viewer3d/viewer3d.cpp +++ b/src/viewer3d/viewer3d.cpp @@ -29,6 +29,11 @@ #include "geometry/geometry.h" #include "renderer/renderer.h" +// Environment used when the host app doesn't pick one. The rotation turns the +// HDRI's bright side towards the default camera; see View3DWidget's env list. +static const char* kFallbackEnvPath = ":env/studio_kontrast_03_1k.hdr"; +static const float kFallbackEnvRotation = 160.0f; + QOpenGLShaderProgram* createMainShader(); QOpenGLBuffer* loadMesh(); Mesh* loadMeshFromRc(const QString& path); @@ -111,14 +116,15 @@ void Viewer3D::initializeGL() renderer = new Renderer(); renderer->init(this->gl); if (!defaultEnvPath.isEmpty()) - renderer->loadEnvironment(defaultEnvPath); + renderer->loadEnvironment(defaultEnvPath, defaultEnvRotation); else - renderer->loadEnvironment(":env/studio_kontrast_03_1k.hdr"); + renderer->loadEnvironment(kFallbackEnvPath, kFallbackEnvRotation); } -void Viewer3D::setDefaultEnvironment(const QString path) +void Viewer3D::setDefaultEnvironment(const QString path, float rotation) { this->defaultEnvPath = path; + this->defaultEnvRotation = rotation; } void Viewer3D::paintGL() @@ -599,10 +605,18 @@ void Viewer3D::resetCamera() this->repaint(); } -void Viewer3D::loadEnvironment(const QString path) +void Viewer3D::loadEnvironment(const QString path, float rotation) +{ + if (renderer) { + renderer->loadEnvironment(path, rotation); + this->repaint(); + } +} + +void Viewer3D::setEnvironmentRotation(float rotation) { if (renderer) { - renderer->loadEnvironment(path); + renderer->setEnvironmentRotation(rotation); this->repaint(); } } diff --git a/src/viewer3d/viewer3d.h b/src/viewer3d/viewer3d.h index 6e2aeea9..474e2ccb 100644 --- a/src/viewer3d/viewer3d.h +++ b/src/viewer3d/viewer3d.h @@ -42,6 +42,7 @@ class Viewer3D : public QOpenGLWidget { Mesh* gltfMesh = nullptr; Mesh* skydomeMesh = nullptr; QString defaultEnvPath; + float defaultEnvRotation = 0.0f; QOpenGLFunctions* gl = nullptr; @@ -118,9 +119,12 @@ class Viewer3D : public QOpenGLWidget { void clearTextures(); void resetCamera(); - void loadEnvironment(const QString path); + // rotation is a yaw in degrees about the up axis, used to turn the lit + // side of the HDRI towards the camera. + void loadEnvironment(const QString path, float rotation = 0.0f); + void setEnvironmentRotation(float rotation); void setModel(const QString& modelType); // sets env to use on load - void setDefaultEnvironment(const QString path); + void setDefaultEnvironment(const QString path, float rotation = 0.0f); }; \ No newline at end of file From edbd459162d7cee3763a8d5f12c6790acd83ce35 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 30 Aug 2026 12:43:57 -0500 Subject: [PATCH 159/164] adjust studio rotation --- src/texturelab/widgets/view3dwidget.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/texturelab/widgets/view3dwidget.cpp b/src/texturelab/widgets/view3dwidget.cpp index 7e91228b..e0d921a1 100644 --- a/src/texturelab/widgets/view3dwidget.cpp +++ b/src/texturelab/widgets/view3dwidget.cpp @@ -29,12 +29,12 @@ const QVector& environments() {"Golden Bay", ":env/golden_bay_1k.hdr", -85.0f}, {"Little Paris Eiffel Tower", ":env/little_paris_eiffel_tower_1k.hdr", -95.0f}, - {"Sepulchral Chapel Basement", - ":env/sepulchral_chapel_basement_1k.hdr", 25.0f}, + {"Sepulchral Chapel Basement", ":env/sepulchral_chapel_basement_1k.hdr", + 25.0f}, {"St Peters Square Night", ":env/st_peters_square_night_1k.hdr", -95.0f}, {"Stadium 01", ":env/stadium_01_1k.hdr", -120.0f}, - {"Studio Kontrast 03", ":env/studio_kontrast_03_1k.hdr", 160.0f}, + {"Studio Kontrast 03", ":env/studio_kontrast_03_1k.hdr", 100.0f}, {"University Workshop", ":env/university_workshop_1k.hdr", 140.0f}, {"Winter River", ":env/winter_river_1k.hdr", -100.0f}}; return envList; From 5539f03985bce7b65dddbcdce8b1a94fae9d0f85 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 30 Aug 2026 13:04:59 -0500 Subject: [PATCH 160/164] add shortcut keys --- src/texturelab/mainwindow.cpp | 34 +++++++++++++++++++------ src/texturelab/widgets/graphwidget.cpp | 35 +++++++++++++++----------- src/texturelab/widgets/graphwidget.h | 9 +++++++ 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index 8c26cd9c..de2b2415 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -94,7 +94,6 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) this->renderer->update(); }); - this->setupMenus(); this->setupToolbar(); this->renderer = nullptr; @@ -177,6 +176,9 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) this->setupDocks(); applySplitterWidth(); + // After the docks: the Edit menu reuses the graph's clipboard actions. + this->setupMenus(); + // setup callbacks for the widgets that are created once connect(this->graphWidget, &GraphWidget::nodeSelectionChanged, [this](const TextureNodePtr& node) { @@ -502,11 +504,15 @@ void MainWindow::setProject(TextureProjectPtr project) void MainWindow::setupMenus() { auto fileMenu = this->menuBar()->addMenu("File"); - fileMenu->addAction("Open Project", [=]() { this->openProject(); }); - fileMenu->addAction("New Project", [=]() { this->newProject(); }); + fileMenu->addAction("Open Project", QKeySequence::Open, + [=]() { this->openProject(); }); + fileMenu->addAction("New Project", QKeySequence::New, + [=]() { this->newProject(); }); fileMenu->addSeparator(); - fileMenu->addAction("Save", [=]() { this->saveProject(); }); - fileMenu->addAction("Save As...", [=]() { this->saveProjectAs(); }); + fileMenu->addAction("Save", QKeySequence::Save, + [=]() { this->saveProject(); }); + fileMenu->addAction("Save As...", QKeySequence::SaveAs, + [=]() { this->saveProjectAs(); }); fileMenu->addSeparator(); recentFilesMenu = fileMenu->addMenu("Open Recent"); @@ -523,9 +529,14 @@ void MainWindow::setupMenus() auto redoAction = undoStack->createRedoAction(this, tr("Redo")); redoAction->setShortcut(QKeySequence::Redo); editMenu->addAction(redoAction); - editMenu->addAction("Cut", [=]() { graphWidget->executeCut(); }); - editMenu->addAction("Copy", [=]() { graphWidget->executeCopy(); }); - editMenu->addAction("Paste", [=]() { graphWidget->executePaste(); }); + editMenu->addSeparator(); + + // The graph owns these — their shortcuts are scoped to it, so Ctrl+C in a + // property field still copies text. Reusing the actions here keeps the keys + // visible in the menu without registering a second, ambiguous binding. + editMenu->addAction(graphWidget->cutAction); + editMenu->addAction(graphWidget->copyAction); + editMenu->addAction(graphWidget->pasteAction); auto examplesMenu = this->menuBar()->addMenu("Examples"); @@ -650,13 +661,20 @@ void MainWindow::setupToolbar() exportBtn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); auto directExportAction = new QAction("Export", this); + directExportAction->setShortcut(QKeySequence("Ctrl+E")); connect(directExportAction, &QAction::triggered, this, &MainWindow::directExport); auto settingsAction = new QAction("Export Settings...", this); + settingsAction->setShortcut(QKeySequence("Ctrl+Shift+E")); connect(settingsAction, &QAction::triggered, this, &MainWindow::showExportDialog); + // The dropdown is a popup window of its own, so associate both actions + // with the main window too or their shortcuts never fire. + this->addAction(directExportAction); + this->addAction(settingsAction); + auto exportMenu = new QMenu(this); exportMenu->addAction(settingsAction); diff --git a/src/texturelab/widgets/graphwidget.cpp b/src/texturelab/widgets/graphwidget.cpp index ade15dbe..ac366451 100644 --- a/src/texturelab/widgets/graphwidget.cpp +++ b/src/texturelab/widgets/graphwidget.cpp @@ -1,6 +1,7 @@ #include "graphwidget.h" #include "../clipboard.h" #include "../undo/undocommands.h" +#include #include #include #include @@ -11,7 +12,6 @@ #include #include #include -#include #include #include #include @@ -209,19 +209,26 @@ GraphWidget::GraphWidget() : QMainWindow(nullptr) // library = nullptr; - auto copyShortcut = new QShortcut(QKeySequence::Copy, this); - copyShortcut->setContext(Qt::WidgetWithChildrenShortcut); - connect(copyShortcut, &QShortcut::activated, this, - &GraphWidget::executeCopy); - - auto cutShortcut = new QShortcut(QKeySequence::Cut, this); - cutShortcut->setContext(Qt::WidgetWithChildrenShortcut); - connect(cutShortcut, &QShortcut::activated, this, &GraphWidget::executeCut); - - auto pasteShortcut = new QShortcut(QKeySequence::Paste, this); - pasteShortcut->setContext(Qt::WidgetWithChildrenShortcut); - connect(pasteShortcut, &QShortcut::activated, this, - &GraphWidget::executePaste); + // Actions rather than plain shortcuts, so the main window's Edit menu can + // reuse them and display the keys. The widget context keeps them off text + // fields in the other docks. + auto makeClipboardAction = [this](const QString& text, + QKeySequence::StandardKey key, + void (GraphWidget::*slot)()) { + auto action = new QAction(text, this); + action->setShortcut(key); + action->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(action, &QAction::triggered, this, slot); + this->addAction(action); + return action; + }; + + cutAction = makeClipboardAction("Cut", QKeySequence::Cut, + &GraphWidget::executeCut); + copyAction = makeClipboardAction("Copy", QKeySequence::Copy, + &GraphWidget::executeCopy); + pasteAction = makeClipboardAction("Paste", QKeySequence::Paste, + &GraphWidget::executePaste); } void GraphWidget::setupToolbar() diff --git a/src/texturelab/widgets/graphwidget.h b/src/texturelab/widgets/graphwidget.h index d0407dd0..5a21851b 100644 --- a/src/texturelab/widgets/graphwidget.h +++ b/src/texturelab/widgets/graphwidget.h @@ -7,6 +7,7 @@ #include #include +class QAction; class QDragEnterEvent; class TextureRenderer; @@ -55,6 +56,14 @@ class GraphWidget : public QMainWindow { TextureRenderer* renderer; QUndoStack* undoStack = nullptr; + // Clipboard actions. These own the Cut/Copy/Paste shortcuts, scoped to + // this widget so line edits elsewhere in the window keep their own, and + // are reused by the main window's Edit menu so it shows the same keys + // without registering a second, ambiguous binding. + QAction* cutAction; + QAction* copyAction; + QAction* pasteAction; + protected: void addNode(const TextureNodePtr& node); void addItemFromSearch(const QString& name, PopupItemType type, From 4b90b409288e23cd2c91392af778fd23304dae39 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 30 Aug 2026 13:22:52 -0500 Subject: [PATCH 161/164] add checkbox to selected model/sky --- src/texturelab/assets.qrc | 1 + src/texturelab/widgets/view3dwidget.cpp | 72 ++++++++++++++----------- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/src/texturelab/assets.qrc b/src/texturelab/assets.qrc index aa8b3be2..40ac9ffd 100644 --- a/src/texturelab/assets.qrc +++ b/src/texturelab/assets.qrc @@ -110,6 +110,7 @@ ../../public/assets/env/st_peters_square_night/st_peters_square_night_1k.hdr ../../public/assets/env/stadium_01/stadium_01_1k.hdr ../../public/assets/env/studio_kontrast_03/studio_kontrast_03_1k.hdr + ../../public/assets/env/sunny_rose_garden_1k.hdr ../../public/assets/env/university_workshop/university_workshop_1k.hdr ../../public/assets/env/winter_river/winter_river_1k.hdr diff --git a/src/texturelab/widgets/view3dwidget.cpp b/src/texturelab/widgets/view3dwidget.cpp index e0d921a1..55bf3c23 100644 --- a/src/texturelab/widgets/view3dwidget.cpp +++ b/src/texturelab/widgets/view3dwidget.cpp @@ -1,6 +1,7 @@ #include "view3dwidget.h" #include "viewer3d.h" #include +#include #include #include #include @@ -8,6 +9,25 @@ namespace { +// Available meshes. `modelType` is the id Viewer3D::setModel() switches on. +struct ModelInfo { + QString displayName; + QString modelType; +}; + +const QVector& models() +{ + static const QVector modelList = { + {"Sphere", "sphere"}, + {"Plane (XY)", "plane_xy"}, + {"Plane (YZ)", "plane_yz"}, + {"Plane (XZ)", "plane_xz"}, + {"Cylinder", "cylinder"}, + {"Cube", "cube"}, + {"CubeSphere", "cubesphere"}}; + return modelList; +} + // Available HDR environments. // // `rotation` is a yaw in degrees about the up axis, applied to both the skybox @@ -35,6 +55,7 @@ const QVector& environments() -95.0f}, {"Stadium 01", ":env/stadium_01_1k.hdr", -120.0f}, {"Studio Kontrast 03", ":env/studio_kontrast_03_1k.hdr", 100.0f}, + {"Sunny Rose Garden", ":env/sunny_rose_garden_1k.hdr", -95.0f}, {"University Workshop", ":env/university_workshop_1k.hdr", 140.0f}, {"Winter River", ":env/winter_river_1k.hdr", -100.0f}}; return envList; @@ -68,42 +89,33 @@ View3DWidget::View3DWidget() QMenuBar* menuBar = new QMenuBar(this); this->setMenuBar(menuBar); - // Model menu + // Model menu. Exclusive like the environment menu below, so the mesh in + // use carries a check mark. The initial check matches the mesh Viewer3D + // builds in its constructor. QMenu* modelMenu = menuBar->addMenu("Model"); + QActionGroup* modelGroup = new QActionGroup(this); + const QString defaultModelType = QStringLiteral("sphere"); + + for (const ModelInfo& model : models()) { + QAction* modelAction = modelMenu->addAction(model.displayName); + modelAction->setCheckable(true); + modelAction->setChecked(model.modelType == defaultModelType); + modelGroup->addAction(modelAction); + QString modelType = model.modelType; + connect(modelAction, &QAction::triggered, + [this, modelType]() { this->viewer->setModel(modelType); }); + } - QAction* sphereAction = modelMenu->addAction("Sphere"); - connect(sphereAction, &QAction::triggered, - [this]() { this->viewer->setModel("sphere"); }); - - QAction* planeXYAction = modelMenu->addAction("Plane (XY)"); - connect(planeXYAction, &QAction::triggered, - [this]() { this->viewer->setModel("plane_xy"); }); - - QAction* planeYZAction = modelMenu->addAction("Plane (YZ)"); - connect(planeYZAction, &QAction::triggered, - [this]() { this->viewer->setModel("plane_yz"); }); - - QAction* planeXZAction = modelMenu->addAction("Plane (XZ)"); - connect(planeXZAction, &QAction::triggered, - [this]() { this->viewer->setModel("plane_xz"); }); - - QAction* cylinderAction = modelMenu->addAction("Cylinder"); - connect(cylinderAction, &QAction::triggered, - [this]() { this->viewer->setModel("cylinder"); }); - - QAction* cubeAction = modelMenu->addAction("Cube"); - connect(cubeAction, &QAction::triggered, - [this]() { this->viewer->setModel("cube"); }); - - QAction* cubesphereAction = modelMenu->addAction("CubeSphere"); - connect(cubesphereAction, &QAction::triggered, - [this]() { this->viewer->setModel("cubesphere"); }); - - // Environment menu + // Environment menu. The entries form an exclusive group so the sky in use + // carries a check mark. QMenu* envMenu = menuBar->addMenu("Environment"); + QActionGroup* envGroup = new QActionGroup(this); for (const EnvInfo& env : environments()) { QAction* envAction = envMenu->addAction(env.displayName); + envAction->setCheckable(true); + envAction->setChecked(env.resourcePath == defaultEnv.resourcePath); + envGroup->addAction(envAction); QString path = env.resourcePath; float rotation = env.rotation; connect(envAction, &QAction::triggered, [this, path, rotation]() { From d8bfa02ccef38d8ae3d712fdea0dd5bee8bb7d78 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Sun, 30 Aug 2026 16:47:46 -0500 Subject: [PATCH 162/164] update textures to libv3 --- public/assets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/assets b/public/assets index 3133e228..0aeea6c2 160000 --- a/public/assets +++ b/public/assets @@ -1 +1 @@ -Subproject commit 3133e228b6a24d9e944c07899c9ecd446931f77a +Subproject commit 0aeea6c2358099025e1a19eeeac01f62420bc6fb From a1ee7374c8e73094345a06440cc29361f9b0fe89 Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Mon, 31 Aug 2026 21:27:05 -0500 Subject: [PATCH 163/164] add breadcrumbs for better error handling and handle resolution change based crashes --- src/texturelab/CMakeLists.txt | 2 + src/texturelab/catalogservice.cpp | 5 +- src/texturelab/graphics/renderworker.cpp | 47 +++- src/texturelab/graphics/texturerenderer.cpp | 176 ++++++++++++- src/texturelab/graphics/texturerenderer.h | 38 ++- src/texturelab/main.cpp | 7 + src/texturelab/mainwindow.cpp | 49 ++-- src/texturelab/systeminfo.cpp | 244 ++++++++++++++++++ src/texturelab/systeminfo.h | 35 +++ src/texturelab/telemetry.cpp | 62 +++++ src/texturelab/telemetry.h | 25 ++ src/texturelab/undo/addnodecommand.cpp | 9 + src/texturelab/undo/deleteitemscommand.cpp | 13 + src/texturelab/undo/pastecommand.cpp | 12 + src/texturelab/widgets/crashconsentdialog.cpp | 9 +- src/texturelab/widgets/graphwidget.cpp | 100 ++++++- src/texturelab/widgets/graphwidget.h | 9 + 17 files changed, 800 insertions(+), 42 deletions(-) create mode 100644 src/texturelab/systeminfo.cpp create mode 100644 src/texturelab/systeminfo.h diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index a40f3e13..3979e649 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -156,6 +156,8 @@ set(PROJECT_SOURCES ./main.cpp ./telemetry.h ./telemetry.cpp + ./systeminfo.h + ./systeminfo.cpp ./catalogservice.h ./catalogservice.cpp ./launcher/launcherwindow.h diff --git a/src/texturelab/catalogservice.cpp b/src/texturelab/catalogservice.cpp index 973e3767..a124e8a1 100644 --- a/src/texturelab/catalogservice.cpp +++ b/src/texturelab/catalogservice.cpp @@ -200,7 +200,10 @@ bool CatalogService::init() if (!catalogIndex.isReadOnly()) seedFromRecentFiles(); - Telemetry::breadcrumb("catalog", "opened index at " + indexPath().toStdString()); + // No path: it sits under the user's home directory, and the consent prompt + // says we don't send folder paths. + Telemetry::breadcrumb("catalog", "index opened", + {{"read_only", catalogIndex.isReadOnly()}}); return true; } diff --git a/src/texturelab/graphics/renderworker.cpp b/src/texturelab/graphics/renderworker.cpp index 4375d091..c66a92fa 100644 --- a/src/texturelab/graphics/renderworker.cpp +++ b/src/texturelab/graphics/renderworker.cpp @@ -1,5 +1,6 @@ #include "renderworker.h" #include "../models.h" +#include "../systeminfo.h" #include "../telemetry.h" #include "../curve.h" #include "gradient.h" @@ -28,6 +29,25 @@ const int TEXTURE_SIZE = 1024; RENDERDOC_API_1_1_2* rdoc_api = nullptr; +namespace { + +// Attach GL/VRAM state to a Sentry event before a qFatal takes the process +// down. Continuing past a broken framebuffer on the render thread isn't safe, +// but these used to arrive as bare aborts with an unreadable driver stack. +void reportFatalGlState(const char* what, Telemetry::Fields extra) +{ + const SystemInfo::GpuMemory mem = SystemInfo::queryGpuMemory(); + extra.emplace_back("vram_known", mem.known); + extra.emplace_back("vram_total_mb", + mem.known ? mem.totalKb / 1024 : (int64_t)-1); + extra.emplace_back("vram_available_mb", + mem.known ? mem.availableKb / 1024 : (int64_t)-1); + extra.emplace_back("thread", std::string("render worker")); + Telemetry::captureException(what, extra); +} + +} // namespace + RenderWorker::RenderWorker() : QObject(), surface(nullptr), ctx(nullptr), gl(nullptr), vao(nullptr), vbo(nullptr), vshader(nullptr), fshader(nullptr), fbo(nullptr), @@ -100,6 +120,7 @@ void RenderWorker::setup() ctx->setShareContext(QOpenGLContext::globalShareContext()); ctx->setFormat(format); if (!ctx->create()) { + reportFatalGlState("render worker context creation failed", {}); qFatal("unable to create surface!"); } @@ -109,6 +130,11 @@ void RenderWorker::setup() // https://doc-snapshots.qt.io/qt6-dev/gui-changes-qt6.html gl = QOpenGLVersionFunctionsFactory::get(ctx); if (!gl) { + reportFatalGlState("3.2 core functions unavailable on worker context", + {{"granted_major", + (int64_t)ctx->format().majorVersion()}, + {"granted_minor", + (int64_t)ctx->format().minorVersion()}}); qFatal("Could not obtain required OpenGL context version"); } @@ -199,6 +225,8 @@ void RenderWorker::setup() // https://www.qt.io/blog/2015/09/21/using-modern-opengl-es-features-with-qopenglframebufferobject-in-qt-5-6 fbo = new QOpenGLFramebufferObject(TEXTURE_SIZE, TEXTURE_SIZE); if (!fbo->isValid()) { + reportFatalGlState("render worker scratch FBO could not be created", + {{"size", (int64_t)TEXTURE_SIZE}}); qFatal("FBO could not be created"); } @@ -210,7 +238,16 @@ void RenderWorker::setup() // gl->glReadBuffer(GL_NONE); gl->glBindFramebuffer(GL_FRAMEBUFFER, 0); - Telemetry::breadcrumb("render.setup", "RenderWorker::setup() complete"); + { + const QSurfaceFormat granted = ctx->format(); + Telemetry::breadcrumb( + "render.setup", "RenderWorker::setup() complete", + {{"granted_version", + std::to_string(granted.majorVersion()) + "." + + std::to_string(granted.minorVersion())}, + {"core_profile", + granted.profile() == QSurfaceFormat::CoreProfile}}); + } // Initialize resource cache for custom node renderers resourceCache.init(gl, fboId); @@ -229,7 +266,9 @@ void RenderWorker::setup() void RenderWorker::processRenderCommand(const RenderCommand& command) { - Telemetry::breadcrumb("render", "node: " + command.nodeId.toStdString()); + // Deliberately no breadcrumb here: with the Crashpad backend every crumb + // flushes the scope to disk, and this runs once per node per render pass. + // The batch-level crumbs in TextureRenderer cover what we actually need. if (rdoc_api) rdoc_api->StartFrameCapture(NULL, NULL); @@ -309,6 +348,10 @@ void RenderWorker::renderSinglePass(const RenderCommand& command) GLenum status = gl->glCheckFramebufferStatus(GL_FRAMEBUFFER); if (status != GL_FRAMEBUFFER_COMPLETE) { + reportFatalGlState("render target framebuffer incomplete", + {{"status", (int64_t)status}, + {"width", (int64_t)command.textureWidth}, + {"height", (int64_t)command.textureHeight}}); qFatal("FRAMEBUFFER IS NOT COMPLETE!"); } diff --git a/src/texturelab/graphics/texturerenderer.cpp b/src/texturelab/graphics/texturerenderer.cpp index 093241a0..05cf52f2 100644 --- a/src/texturelab/graphics/texturerenderer.cpp +++ b/src/texturelab/graphics/texturerenderer.cpp @@ -22,6 +22,8 @@ // #include #include "../props.h" +#include "../systeminfo.h" +#include "../telemetry.h" #include "models.h" // #define RENDER_IN_MAIN_THREAD @@ -40,6 +42,27 @@ enum class VertexUsage : int { const int TEXTURE_SIZE = 1024; +// Test hook for the out-of-VRAM path. A dev box with a 6 GB card will never +// actually fail at 4K, so `TEXTURELAB_FBO_FAIL_ABOVE=2048` makes +// createNodeTexture() report failure above that size and lets the rollback, +// the dialog and the Sentry payload be exercised deterministically. +// Dev builds only — it must not be reachable in a release binary. +static bool shouldFailTextureAllocation(int resolution) +{ +#ifdef TEXTURELAB_DEV_BUILD + static const int threshold = []() { + bool ok = false; + const int value = + qEnvironmentVariableIntValue("TEXTURELAB_FBO_FAIL_ABOVE", &ok); + return ok ? value : 0; + }(); + return threshold > 0 && resolution > threshold; +#else + Q_UNUSED(resolution); + return false; +#endif +} + // https://github.com/cromop/mOffscreenRendering/blob/master/OGLWidget.cpp // https://github.com/florianblume/Qt3D-OffscreenRenderer/blob/master/offscreensurfaceframegraph.h // https://stackoverflow.com/questions/60515589/offscreen-render-with-qoffscreensurface-using-docker @@ -262,6 +285,10 @@ void TextureRenderer::setup() qFatal("FBO could not be created"); } + // Our context is current here and this is the GUI thread, so this is the + // one place guaranteed to be able to ask the driver what hardware we're on. + SystemInfo::reportGpuContext(); + this->initRenderWorker(); } @@ -335,6 +362,9 @@ void TextureRenderer::update() if (!project) return; + const int requested = project->textureWidth; + bool allocationFailed = false; + // check for nodes that need updating and update for (auto& node : project->nodes) { // Defensive: a null entry should never reach the map now that lookups @@ -344,7 +374,10 @@ void TextureRenderer::update() if (!node->isGraphicsResourcesInitialized()) { // create texture - initializeNodeGraphicsResources(node); + if (!initializeNodeGraphicsResources(node)) { + allocationFailed = true; + break; + } } // if the resolution has changed, resize texture @@ -356,12 +389,65 @@ void TextureRenderer::update() if (!renderInFlight && (project->textureWidth != node->textureWidth || project->textureHeight != node->textureHeight)) { - this->createNodeTexture(node); + if (!this->createNodeTexture(node)) { + allocationFailed = true; + break; + } // clear pixmap and emit thumbnail changed? } } + if (allocationFailed) { + // Out of VRAM part-way through the batch. Retreat to the last size we + // know fits rather than leaving half the graph unallocated (and, before + // this path existed, aborting the process outright). + rollBackResolution(requested); + return; + } + + if (lastGoodResolution != requested) { + lastGoodResolution = requested; + Telemetry::setTag("texture.resolution", std::to_string(requested)); + } + + if (!renderInFlight) + this->queueNextNodeToRender(); +} + +void TextureRenderer::rollBackResolution(int requested) +{ + // Nothing known-good to retreat to — the very first allocation failed, so + // there is no smaller size on record. Leave the graph unrendered; the + // captureException in createNodeTexture() has already reported why. + if (lastGoodResolution <= 0 || lastGoodResolution == requested) + return; + + const int fallback = lastGoodResolution; + project->textureWidth = fallback; + project->textureHeight = fallback; + + Telemetry::breadcrumb( + "render", "resolution rolled back after allocation failure", + {{"requested", (int64_t)requested}, + {"fallback", (int64_t)fallback}, + {"node_count", (int64_t)project->nodes.size()}}); + Telemetry::setTag("texture.resolution", std::to_string(fallback)); + + // Re-allocate everything at the size we know fits. A node whose texture was + // freed on the way up gets it back here; one that still fails is skipped by + // getNextUpdatableNode() rather than dereferenced. + for (auto& node : project->nodes) { + if (!node) + continue; + if (!node->texture || node->textureWidth != fallback || + node->textureHeight != fallback) + this->createNodeTexture(node); + node->isDirty = true; + } + + emit resolutionChangeFailed(requested, fallback); + if (!renderInFlight) this->queueNextNodeToRender(); } @@ -421,18 +507,38 @@ void TextureRenderer::updateOld() ctx->doneCurrent(); } -void TextureRenderer::initializeNodeGraphicsResources( +bool TextureRenderer::initializeNodeGraphicsResources( const TextureNodePtr& node) { - this->createNodeTexture(node); + if (!this->createNodeTexture(node)) + return false; ctx->makeCurrent(surface); // build and compile shaders node->shader = buildShaderForNode(node); ctx->doneCurrent(); + + return true; } -void TextureRenderer::createNodeTexture(const TextureNodePtr& node) +SystemInfo::GpuMemory TextureRenderer::queryGpuMemory() +{ + // Same save/restore dance as handleExport(): the caller may be inside a + // widget's paint or event handling with its own context bound. + QOpenGLContext* previous = QOpenGLContext::currentContext(); + QSurface* previousSurface = previous ? previous->surface() : nullptr; + + ctx->makeCurrent(surface); + const SystemInfo::GpuMemory mem = SystemInfo::queryGpuMemory(); + ctx->doneCurrent(); + + if (previous && previousSurface) + previous->makeCurrent(previousSurface); + + return mem; +} + +bool TextureRenderer::createNodeTexture(const TextureNodePtr& node) { ctx->makeCurrent(surface); @@ -444,16 +550,56 @@ void TextureRenderer::createNodeTexture(const TextureNodePtr& node) node->texture = nullptr; } + const int width = project->textureWidth; + const int height = project->textureHeight; + + // Start from a clean slate so the GL_OUT_OF_MEMORY check below can only be + // reporting on this allocation. + while (gl->glGetError() != GL_NO_ERROR) { + } + // create fbo QOpenGLFramebufferObjectFormat fboFormat; fboFormat.setInternalTextureFormat(GL_RGBA32F); - node->texture = new QOpenGLFramebufferObject( - project->textureWidth, project->textureHeight, fboFormat); - node->textureWidth = project->textureWidth; - node->textureHeight = project->textureHeight; + node->texture = new QOpenGLFramebufferObject(width, height, fboFormat); + node->textureWidth = width; + node->textureHeight = height; + + // Running out of VRAM shows up either as an incomplete FBO or as a + // GL_OUT_OF_MEMORY left behind by the texture allocation, depending on the + // driver. 4096x4096 RGBA32F is 256 MiB per node, so a graph of any size at + // 4K will exhaust a 1-2 GB card — this used to qFatal() and take the whole + // app down with an unreadable driver-side stack. + const GLenum err = gl->glGetError(); + const bool failed = !node->texture->isValid() || err == GL_OUT_OF_MEMORY || + shouldFailTextureAllocation(width); + + if (failed) { + delete node->texture; + node->texture = nullptr; - if (!node->texture->isValid()) { - qFatal("FBO could not be created"); + const SystemInfo::GpuMemory mem = SystemInfo::queryGpuMemory(); + + ctx->doneCurrent(); + + Telemetry::captureException( + "node texture allocation failed", + {{"width", (int64_t)width}, + {"height", (int64_t)height}, + {"bytes_per_node", estimatedNodeTextureBytes(width)}, + {"node_count", (int64_t)(project ? project->nodes.size() : 0)}, + {"estimated_total_bytes", + estimatedNodeTextureBytes(width) * + (int64_t)(project ? project->nodes.size() : 0)}, + {"gl_error", (int64_t)err}, + {"vram_known", mem.known}, + {"vram_total_mb", mem.known ? mem.totalKb / 1024 : (int64_t)-1}, + {"vram_available_mb", + mem.known ? mem.availableKb / 1024 : (int64_t)-1}}); + + qWarning("node texture allocation failed at %dx%d (glGetError 0x%04x)", + width, height, err); + return false; } // make texture wrap @@ -467,6 +613,8 @@ void TextureRenderer::createNodeTexture(const TextureNodePtr& node) gl->glFlush(); ctx->doneCurrent(); + + return true; } void TextureRenderer::renderNode(const TextureNodePtr& node) @@ -738,6 +886,12 @@ TextureNodePtr TextureRenderer::getNextUpdatableNode() const if (!node->isDirty) continue; + // A node whose texture/shader allocation failed has no FBO to render + // into; queueNextNodeToRender() would dereference it. Skip rather than + // crash — update() has already reported and rolled back. + if (!node->isGraphicsResourcesInitialized()) + continue; + auto hasCleanDeps = true; // we have a dirty node, check if all deps are clean diff --git a/src/texturelab/graphics/texturerenderer.h b/src/texturelab/graphics/texturerenderer.h index 79e9b0dd..db866dfa 100644 --- a/src/texturelab/graphics/texturerenderer.h +++ b/src/texturelab/graphics/texturerenderer.h @@ -5,6 +5,10 @@ #include #include +#include "../systeminfo.h" + +#include + class QOffscreenSurface; class QOpenGLContext; class QOpenGLFunctions_3_2_Core; @@ -45,6 +49,11 @@ class TextureRenderer : public QObject { // captured as inputs in the in-flight command. bool renderInFlight = false; + // The last resolution every node was successfully allocated at. A failed + // resize (4K on a small-VRAM GPU is the case that prompted this) rolls the + // project back to it instead of aborting the process. + int lastGoodResolution = 0; + public: TextureRenderer(); ~TextureRenderer(); @@ -54,15 +63,35 @@ class TextureRenderer : public QObject { void updateOld(); void testRendering(); - void initializeNodeGraphicsResources(const TextureNodePtr& node); - void createNodeTexture(const TextureNodePtr& node); + // False when the node's texture could not be allocated. + bool initializeNodeGraphicsResources(const TextureNodePtr& node); + // False when the FBO could not be allocated — almost always the GPU being + // out of memory for a w*h*16-byte RGBA32F target per node. + bool createNodeTexture(const TextureNodePtr& node); void renderNode(const TextureNodePtr& node); + // Free/total VRAM as the driver reports it, queried on this renderer's own + // context. Callers on the GUI thread generally have no context current, and + // SystemInfo::queryGpuMemory() needs one — going through here rather than + // relying on whichever context a widget happened to leave bound. + SystemInfo::GpuMemory queryGpuMemory(); + + // Bytes of VRAM one node's texture needs at the given square resolution. + // RGBA32F: 4 channels x 4 bytes. + static int64_t estimatedNodeTextureBytes(int resolution) + { + return (int64_t)resolution * resolution * 16; + } + TextureProjectPtr project; QOffscreenSurface* surface; QOpenGLContext* ctx; private: + // Retreat to lastGoodResolution after a failed allocation batch, re-allocate + // there, and emit resolutionChangeFailed(). + void rollBackResolution(int requested); + void initRenderWorker(); void nodeRendered(const QString& nodeId, GLuint texId); void queueNextNodeToRender(); @@ -75,6 +104,11 @@ class TextureRenderer : public QObject { void thumbnailGenerated(const QString& nodeId, GLuint texId, const QPixmap& pixmap); void renderProgress(int clean, int total); + + // The project could not be allocated at `requested`; it has been rolled + // back to `fallback` and is rendering normally again. GraphWidget uses this + // to tell the user and put the resolution picker back. + void resolutionChangeFailed(int requested, int fallback); }; // note: there's no specified fbo limit diff --git a/src/texturelab/main.cpp b/src/texturelab/main.cpp index 78847957..f36f84a7 100644 --- a/src/texturelab/main.cpp +++ b/src/texturelab/main.cpp @@ -1,5 +1,6 @@ #include "catalogservice.h" #include "mainwindow.h" +#include "systeminfo.h" #include "telemetry.h" #include "thememanager.h" #include "version.h" @@ -135,6 +136,12 @@ int main(int argc, char* argv[]) // Now applicationDirPath() is valid — init Sentry Telemetry::init(crashReportingEnabled); + // Register RAM/CPU/screen/platform context before anything touches OpenGL. + // The GPU half needs a current context and is registered later, from + // TextureRenderer::setup(); this half has to already be on the scope in + // case we die during GL init itself. + SystemInfo::reportSystemContext(); + // Launcher index + thumbnail cache. Failure is not fatal: the app runs // normally without them, it just has nothing to show in the launcher. // Seeds from the legacy recent-files list on first run (LAUNCHER_PRD.md §1.1). diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index de2b2415..55ad30aa 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -288,23 +288,8 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) auto project = TextureProject::createEmpty(); this->setProject(project); - // Print GPU information - QOpenGLContext* context = QOpenGLContext::currentContext(); - if (context) { - QOpenGLFunctions* f = context->functions(); - const GLubyte* vendor = f->glGetString(GL_VENDOR); - const GLubyte* renderer = f->glGetString(GL_RENDERER); - const GLubyte* version = f->glGetString(GL_VERSION); - - qDebug() << "=== GPU Information ==="; - qDebug() << "GPU Vendor:" << reinterpret_cast(vendor); - qDebug() << "GPU Renderer:" << reinterpret_cast(renderer); - qDebug() << "OpenGL Version:" << reinterpret_cast(version); - qDebug() << "======================"; - } - else { - qDebug() << "Warning: No OpenGL context available yet"; - } + // GPU details go to Sentry from TextureRenderer::setup(), where a + // context is guaranteed current (see SystemInfo::reportGpuContext()). // test texture rendering // auto renderer = new TextureRenderer(); @@ -416,6 +401,15 @@ void MainWindow::setProject(TextureProjectPtr project) this->project = project; this->syncedChannels = project->textureChannels; + + // Tagged rather than only breadcrumbed so a crash event carries the graph + // size and resolution it happened at, which is what the GTX 750 / 4K report + // was missing. + Telemetry::setTag("project.node_count", + std::to_string(project->nodes.size())); + Telemetry::setTag("texture.resolution", + std::to_string(project->textureWidth)); + this->graphWidget->setTextureProject(project); this->syncChannelLabelsToScene(); this->libraryWidget->setLibrary(project->library); @@ -909,8 +903,13 @@ void MainWindow::openProjectFromPath(const QString& filePath) project->name = fileInfo.baseName(); project->filePath = filePath; - Telemetry::breadcrumb("project", - "open: " + fileInfo.baseName().toStdString()); + // Deliberately no file name or path: the consent prompt promises counts and + // settings, not what the user is working on. + Telemetry::breadcrumb("project", "opened", + {{"node_count", (int64_t)project->nodes.size()}, + {"resolution", (int64_t)project->textureWidth}, + {"library_version", + project->libraryVersion.toStdString()}}); setProject(project); addToRecentFiles(filePath); @@ -1019,7 +1018,9 @@ void MainWindow::saveProject() graphWidget->syncPositionsToModel(); - Telemetry::breadcrumb("project", "save: " + project->name.toStdString()); + Telemetry::breadcrumb("project", "saved", + {{"node_count", (int64_t)project->nodes.size()}, + {"resolution", (int64_t)project->textureWidth}}); QFile file(project->filePath); file.open(QIODevice::WriteOnly); file.write(Project::saveTexture(project)); @@ -1110,7 +1111,13 @@ void MainWindow::directExport() void MainWindow::handleExport(const QString& destination, const QString& pattern) { - Telemetry::breadcrumb("project", "export to: " + destination.toStdString()); + // The destination path is deliberately not recorded — only the shape of + // the export. + Telemetry::breadcrumb("export", "export started", + {{"resolution", (int64_t)(this->project + ? this->project->textureWidth + : 0)}, + {"pattern", pattern.toStdString()}}); if (!this->project || !this->renderer) { QMessageBox::warning(this, "Export Error", "No project loaded or renderer not initialized."); diff --git a/src/texturelab/systeminfo.cpp b/src/texturelab/systeminfo.cpp new file mode 100644 index 00000000..45c235bc --- /dev/null +++ b/src/texturelab/systeminfo.cpp @@ -0,0 +1,244 @@ +#include "systeminfo.h" + +#include "telemetry.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(Q_OS_LINUX) +#include +#elif defined(Q_OS_WIN) +#include +#elif defined(Q_OS_MACOS) +#include +#include +#endif + +namespace { + +// Not in QOpenGLFunctions' enum set; both extensions are queried through the +// plain glGetIntegerv the base functions object already gives us. +constexpr GLenum kGpuMemInfoDedicatedVidmemNvx = 0x9047; +constexpr GLenum kGpuMemInfoCurrentAvailableVidmemNvx = 0x9049; +constexpr GLenum kTextureFreeMemoryAti = 0x87FC; + +// Windows' GL/gl.h stops at 1.1, so these aren't guaranteed to exist even +// though every context we ever create is 3.2 core. +#ifndef GL_MAX_RENDERBUFFER_SIZE +#define GL_MAX_RENDERBUFFER_SIZE 0x84E8 +#endif +#ifndef GL_MAX_SAMPLES +#define GL_MAX_SAMPLES 0x8D57 +#endif +#ifndef GL_SHADING_LANGUAGE_VERSION +#define GL_SHADING_LANGUAGE_VERSION 0x8B8C +#endif + +std::string glStringOr(QOpenGLFunctions* f, GLenum name, const char* fallback) +{ + const GLubyte* s = f->glGetString(name); + return s ? reinterpret_cast(s) : fallback; +} + +int64_t physicalMemoryKb() +{ +#if defined(Q_OS_LINUX) + struct sysinfo info; + if (sysinfo(&info) == 0) + return (int64_t)info.totalram * info.mem_unit / 1024; +#elif defined(Q_OS_WIN) + MEMORYSTATUSEX status; + status.dwLength = sizeof(status); + if (GlobalMemoryStatusEx(&status)) + return (int64_t)(status.ullTotalPhys / 1024); +#elif defined(Q_OS_MACOS) + int64_t bytes = 0; + size_t len = sizeof(bytes); + if (sysctlbyname("hw.memsize", &bytes, &len, nullptr, 0) == 0) + return bytes / 1024; +#endif + return 0; +} + +int64_t freeMemoryKb() +{ +#if defined(Q_OS_LINUX) + struct sysinfo info; + if (sysinfo(&info) == 0) + return (int64_t)(info.freeram + info.bufferram) * info.mem_unit / 1024; +#elif defined(Q_OS_WIN) + MEMORYSTATUSEX status; + status.dwLength = sizeof(status); + if (GlobalMemoryStatusEx(&status)) + return (int64_t)(status.ullAvailPhys / 1024); +#endif + return 0; +} + +// How this build was delivered, which decides whether a driver-side crash is +// even our code's fault — an AppImage carries its own Qt and libstdc++ into a +// host GL stack it was never built against. +std::string packaging() +{ + const QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + if (env.contains(QStringLiteral("APPIMAGE"))) + return "appimage"; +#if defined(Q_OS_MACOS) + if (QCoreApplication::applicationDirPath().contains(QStringLiteral(".app/Contents/"))) + return "macos-bundle"; +#endif + return "native"; +} + +} // namespace + +SystemInfo::GpuMemory SystemInfo::queryGpuMemory() +{ + GpuMemory mem; + + QOpenGLContext* ctx = QOpenGLContext::currentContext(); + if (!ctx) + return mem; + + QOpenGLFunctions* f = ctx->functions(); + + if (ctx->hasExtension(QByteArrayLiteral("GL_NVX_gpu_memory_info"))) { + GLint total = 0; + GLint available = 0; + f->glGetIntegerv(kGpuMemInfoDedicatedVidmemNvx, &total); + f->glGetIntegerv(kGpuMemInfoCurrentAvailableVidmemNvx, &available); + mem.known = true; + mem.totalKb = total; + mem.availableKb = available; + } + else if (ctx->hasExtension(QByteArrayLiteral("GL_ATI_meminfo"))) { + // Returns 4 ints; the first is the free pool in KB. Total isn't + // exposed, so it stays 0 and callers fall back to the free figure. + GLint values[4] = {0, 0, 0, 0}; + f->glGetIntegerv(kTextureFreeMemoryAti, values); + mem.known = true; + mem.availableKb = values[0]; + } + + // Whatever the query left behind is not our caller's problem to notice. + while (f->glGetError() != GL_NO_ERROR) { + } + + return mem; +} + +void SystemInfo::reportSystemContext() +{ + const int64_t totalRamKb = physicalMemoryKb(); + + Telemetry::Fields device = { + {"arch", QSysInfo::currentCpuArchitecture().toStdString()}, + {"cpu_count", (int64_t)QThread::idealThreadCount()}, + {"memory_size", totalRamKb * 1024}, + {"free_memory", freeMemoryKb() * 1024}, + {"model", QSysInfo::prettyProductName().toStdString()}, + {"kernel_version", QSysInfo::kernelVersion().toStdString()}, + }; + + if (QScreen* screen = QGuiApplication::primaryScreen()) { + device.emplace_back("screen_width_pixels", + (int64_t)screen->geometry().width()); + device.emplace_back("screen_height_pixels", + (int64_t)screen->geometry().height()); + device.emplace_back("screen_density", screen->devicePixelRatio()); + } + device.emplace_back("screen_count", + (int64_t)QGuiApplication::screens().size()); + + Telemetry::setContext("device", device); + + const QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + const std::string pkg = packaging(); + + Telemetry::setContext( + "runtime", + { + {"name", std::string("Qt")}, + {"version", std::string(qVersion())}, + {"build_version", std::string(QT_VERSION_STR)}, + {"platform_plugin", QGuiApplication::platformName().toStdString()}, + {"session_type", + env.value(QStringLiteral("XDG_SESSION_TYPE"), QStringLiteral("unknown")) + .toStdString()}, + {"packaging", pkg}, + }); + + Telemetry::setTag("qt.platform", QGuiApplication::platformName().toStdString()); + Telemetry::setTag("packaging", pkg); +} + +void SystemInfo::reportGpuContext() +{ + QOpenGLContext* ctx = QOpenGLContext::currentContext(); + if (!ctx) { + Telemetry::breadcrumb("gpu", "reportGpuContext called with no current context"); + return; + } + + QOpenGLFunctions* f = ctx->functions(); + + const std::string vendor = glStringOr(f, GL_VENDOR, "unknown"); + const std::string renderer = glStringOr(f, GL_RENDERER, "unknown"); + const std::string version = glStringOr(f, GL_VERSION, "unknown"); + + GLint maxTextureSize = 0; + GLint maxRenderbufferSize = 0; + GLint maxSamples = 0; + f->glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maxTextureSize); + f->glGetIntegerv(GL_MAX_RENDERBUFFER_SIZE, &maxRenderbufferSize); + f->glGetIntegerv(GL_MAX_SAMPLES, &maxSamples); + + const QSurfaceFormat fmt = ctx->format(); + const GpuMemory mem = queryGpuMemory(); + + Telemetry::Fields gpu = { + {"name", renderer}, + {"vendor_name", vendor}, + {"version", version}, + {"api_type", std::string("OpenGL")}, + {"shading_language_version", + glStringOr(f, GL_SHADING_LANGUAGE_VERSION, "unknown")}, + {"granted_version", + std::to_string(fmt.majorVersion()) + "." + std::to_string(fmt.minorVersion())}, + {"granted_profile", + std::string(fmt.profile() == QSurfaceFormat::CoreProfile ? "core" + : fmt.profile() == QSurfaceFormat::CompatibilityProfile + ? "compatibility" + : "none")}, + {"max_texture_size", (int64_t)maxTextureSize}, + {"max_renderbuffer_size", (int64_t)maxRenderbufferSize}, + {"max_samples", (int64_t)maxSamples}, + {"memory_reporting", mem.known}, + }; + + if (mem.known) { + // Sentry renders gpu.memory_size as MB. + gpu.emplace_back("memory_size", mem.totalKb / 1024); + gpu.emplace_back("free_memory_mb", mem.availableKb / 1024); + } + + Telemetry::setContext("gpu", gpu); + + Telemetry::setTag("gpu.vendor", vendor); + Telemetry::setTag("gpu.renderer", renderer); + Telemetry::setTag("gl.version", version); + + Telemetry::breadcrumb("gpu", renderer + " / " + version, + {{"vendor", vendor}, + {"max_texture_size", (int64_t)maxTextureSize}, + {"vram_total_mb", mem.known ? mem.totalKb / 1024 : (int64_t)-1}}); +} diff --git a/src/texturelab/systeminfo.h b/src/texturelab/systeminfo.h new file mode 100644 index 00000000..576614f6 --- /dev/null +++ b/src/texturelab/systeminfo.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +// Hardware and environment reporting for crash triage. +// +// Exists because a Sentry report that says only "it aborted somewhere in the GL +// driver" is unactionable: the GTX 750 / 4K report that prompted this had no +// GPU, no VRAM figure, and no clue that a resolution change had just happened. +// Everything here funnels into Telemetry contexts/tags and no-ops when crash +// reporting is off. +namespace SystemInfo { + +// What the driver will tell us about video memory. `known` is false when +// neither GL_NVX_gpu_memory_info nor GL_ATI_meminfo is advertised — which is +// common enough that callers must handle it rather than assume a number. +struct GpuMemory { + bool known = false; + int64_t totalKb = 0; + int64_t availableKb = 0; +}; + +// Requires a current OpenGL context. Cheap enough to call per user action. +GpuMemory queryGpuMemory(); + +// Register the non-GL half: RAM, CPU, screens, Qt/platform/packaging. +// Call once, before any GL work — the crash we're chasing happened *during* GL +// init, so this has to already be on the scope by then. +void reportSystemContext(); + +// Register the GPU half. Requires a current OpenGL context; call once from +// TextureRenderer::setup() where that's guaranteed. +void reportGpuContext(); + +} // namespace SystemInfo diff --git a/src/texturelab/telemetry.cpp b/src/texturelab/telemetry.cpp index 0bba7d16..d276cc94 100644 --- a/src/texturelab/telemetry.cpp +++ b/src/texturelab/telemetry.cpp @@ -9,6 +9,9 @@ #include #include +#include +#include + static bool g_enabled = false; namespace { @@ -24,6 +27,34 @@ QSettings consentSettings() constexpr const char* kAllowedKey = "crashReporting"; constexpr const char* kAskedVersionKey = "crashReportingConsentVersion"; +// Fields -> sentry object. Kept in one place so breadcrumbs, contexts and +// events all serialize identically. +sentry_value_t toSentryObject(const Telemetry::Fields& fields) +{ + sentry_value_t obj = sentry_value_new_object(); + for (const auto& [key, value] : fields) { + sentry_value_t v = std::visit( + [](const auto& held) -> sentry_value_t { + using T = std::decay_t; + if constexpr (std::is_same_v) + return sentry_value_new_string(held.c_str()); + else if constexpr (std::is_same_v) + // sentry_value_new_int32 would silently truncate byte + // counts, so anything that doesn't fit goes as a double. + return held >= INT32_MIN && held <= INT32_MAX + ? sentry_value_new_int32((int32_t)held) + : sentry_value_new_double((double)held); + else if constexpr (std::is_same_v) + return sentry_value_new_double(held); + else + return sentry_value_new_bool(held); + }, + value); + sentry_value_set_by_key(obj, key.c_str(), v); + } + return obj; +} + } // namespace void Telemetry::init(bool enabled) @@ -111,16 +142,45 @@ void Telemetry::recordConsent(bool allowed) } void Telemetry::breadcrumb(const char* category, const std::string& message) +{ + Telemetry::breadcrumb(category, message, {}); +} + +void Telemetry::breadcrumb(const char* category, const std::string& message, + const Fields& data) { if (!g_enabled) return; sentry_value_t crumb = sentry_value_new_breadcrumb("default", message.c_str()); sentry_value_set_by_key(crumb, "category", sentry_value_new_string(category)); + if (!data.empty()) + sentry_value_set_by_key(crumb, "data", toSentryObject(data)); sentry_add_breadcrumb(crumb); } +void Telemetry::setTag(const char* key, const std::string& value) +{ + if (!g_enabled) + return; + + sentry_set_tag(key, value.c_str()); +} + +void Telemetry::setContext(const char* name, const Fields& fields) +{ + if (!g_enabled) + return; + + sentry_set_context(name, toSentryObject(fields)); +} + void Telemetry::captureException(const std::string& message) +{ + Telemetry::captureException(message, {}); +} + +void Telemetry::captureException(const std::string& message, const Fields& data) { if (!g_enabled) return; @@ -128,5 +188,7 @@ void Telemetry::captureException(const std::string& message) sentry_value_t event = sentry_value_new_event(); sentry_value_t exc = sentry_value_new_exception("Exception", message.c_str()); sentry_event_add_exception(event, exc); + if (!data.empty()) + sentry_value_set_by_key(event, "extra", toSentryObject(data)); sentry_capture_event(event); } diff --git a/src/texturelab/telemetry.h b/src/texturelab/telemetry.h index ca9f35b9..704e9ed5 100644 --- a/src/texturelab/telemetry.h +++ b/src/texturelab/telemetry.h @@ -1,9 +1,19 @@ #pragma once +#include #include +#include +#include +#include namespace Telemetry { +// Structured payload attached to a breadcrumb, context or event. Deliberately +// Qt-free and sentry-free so this header stays cheap to include: the mapping +// onto sentry_value_t lives in telemetry.cpp. +using FieldValue = std::variant; +using Fields = std::vector>; + // Call before QApplication. No-op if DSN is empty or user opted out. void init(bool enabled); @@ -31,9 +41,24 @@ bool consentNeeded(); void recordConsent(bool allowed); // Add a breadcrumb (category + message) to the current session context. +// +// With the Crashpad backend every breadcrumb flushes the scope to disk so it +// survives a hard crash — which is the point, but it also means these belong at +// user-action granularity. Do not add per-node or per-frame breadcrumbs. void breadcrumb(const char* category, const std::string& message); +void breadcrumb(const char* category, const std::string& message, + const Fields& data); + +// Set a searchable tag on every subsequent event. Cheap enough to keep current +// as session state changes (resolution, node count), which is what makes a +// crash event self-describing. +void setTag(const char* key, const std::string& value); + +// Set a structured context block (Sentry's "gpu", "device", "app", ...). +void setContext(const char* name, const Fields& fields); // Capture a handled exception (e.g. from a catch block) as a Sentry error event. void captureException(const std::string& message); +void captureException(const std::string& message, const Fields& data); } // namespace Telemetry diff --git a/src/texturelab/undo/addnodecommand.cpp b/src/texturelab/undo/addnodecommand.cpp index 02e9a367..3e952515 100644 --- a/src/texturelab/undo/addnodecommand.cpp +++ b/src/texturelab/undo/addnodecommand.cpp @@ -2,6 +2,7 @@ #include "../graphics/texturerenderer.h" #include "../libraries/library.h" +#include "../telemetry.h" #include "graph/scene.h" #include @@ -44,6 +45,10 @@ void AddNodeCommand::redo() node->pos = _pos; _project->addNode(node); addNodeToScene(_scene, node); + Telemetry::breadcrumb("graph.node", "node added", + {{"node_count", (int64_t)_project->nodes.size()}}); + Telemetry::setTag("project.node_count", + std::to_string(_project->nodes.size())); if (_renderer) _renderer->update(); } @@ -56,6 +61,10 @@ void AddNodeCommand::undo() // also drops the node's connections, marking every downstream chain dirty _project->removeNode(_nodeId); + Telemetry::breadcrumb("graph.node", "node add undone", + {{"node_count", (int64_t)_project->nodes.size()}}); + Telemetry::setTag("project.node_count", + std::to_string(_project->nodes.size())); if (_renderer) _renderer->update(); } diff --git a/src/texturelab/undo/deleteitemscommand.cpp b/src/texturelab/undo/deleteitemscommand.cpp index 338c1088..8468b86d 100644 --- a/src/texturelab/undo/deleteitemscommand.cpp +++ b/src/texturelab/undo/deleteitemscommand.cpp @@ -3,6 +3,7 @@ #include "../graphics/texturerenderer.h" #include "../libraries/library.h" #include "../props.h" +#include "../telemetry.h" #include "graph/comment.h" #include "graph/frame.h" #include "graph/scene.h" @@ -142,6 +143,12 @@ void DeleteItemsCommand::redo() _project->textureChannels.remove(it.key()); } + Telemetry::breadcrumb("graph.node", "items deleted", + {{"deleted_nodes", (int64_t)_nodes.size()}, + {"node_count", (int64_t)_project->nodes.size()}}); + Telemetry::setTag("project.node_count", + std::to_string(_project->nodes.size())); + if (_renderer) _renderer->update(); } @@ -215,6 +222,12 @@ void DeleteItemsCommand::undo() _project->textureChannels.insert(it.key(), it.value()); } + Telemetry::breadcrumb("graph.node", "delete undone", + {{"restored_nodes", (int64_t)_nodes.size()}, + {"node_count", (int64_t)_project->nodes.size()}}); + Telemetry::setTag("project.node_count", + std::to_string(_project->nodes.size())); + if (_renderer) _renderer->update(); } diff --git a/src/texturelab/undo/pastecommand.cpp b/src/texturelab/undo/pastecommand.cpp index 3b21c427..21e5628d 100644 --- a/src/texturelab/undo/pastecommand.cpp +++ b/src/texturelab/undo/pastecommand.cpp @@ -2,6 +2,7 @@ #include "../clipboard.h" #include "../graphics/texturerenderer.h" +#include "../telemetry.h" #include "graph/comment.h" #include "graph/frame.h" #include "graph/scene.h" @@ -86,6 +87,12 @@ void PasteCommand::redo() gf->setSelected(true); } + Telemetry::breadcrumb("graph.node", "nodes pasted", + {{"pasted_nodes", (int64_t)_nodes.size()}, + {"node_count", (int64_t)_project->nodes.size()}}); + Telemetry::setTag("project.node_count", + std::to_string(_project->nodes.size())); + if (_renderer) _renderer->update(); } @@ -117,6 +124,11 @@ void PasteCommand::undo() _project->frames.remove(frame->id); } + Telemetry::breadcrumb("graph.node", "paste undone", + {{"node_count", (int64_t)_project->nodes.size()}}); + Telemetry::setTag("project.node_count", + std::to_string(_project->nodes.size())); + if (_renderer) _renderer->update(); } diff --git a/src/texturelab/widgets/crashconsentdialog.cpp b/src/texturelab/widgets/crashconsentdialog.cpp index 267ee48d..165be0f4 100644 --- a/src/texturelab/widgets/crashconsentdialog.cpp +++ b/src/texturelab/widgets/crashconsentdialog.cpp @@ -52,12 +52,13 @@ CrashConsentDialog::CrashConsentDialog(QWidget* parent) : QDialog(parent) facts->setSpacing(8); facts->addWidget(buildFact( tr("Sent"), - tr("Where in the code the crash happened, TextureLab's version, and your " - "operating system."))); + tr("Where in the code the crash happened, TextureLab's version, your " + "operating system, your graphics card and driver, and what you were " + "doing just before \u2014 for example \u201cchanged resolution to 4096\u201d."))); facts->addWidget(buildFact( tr("Not sent"), - tr("Your textures. No project files are uploaded, and there is no account or " - "sign-in involved."))); + tr("Your textures. No project files, file names or folder paths are " + "uploaded, and there is no account or sign-in involved."))); layout->addLayout(facts); auto* provider = new QLabel( diff --git a/src/texturelab/widgets/graphwidget.cpp b/src/texturelab/widgets/graphwidget.cpp index ac366451..f4a8b4cd 100644 --- a/src/texturelab/widgets/graphwidget.cpp +++ b/src/texturelab/widgets/graphwidget.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -22,6 +23,8 @@ class NoWheelComboBox : public QComboBox { void wheelEvent(QWheelEvent* event) override { event->ignore(); } }; +#include "../systeminfo.h" +#include "../telemetry.h" #include "./graphics/texturerenderer.h" #include "./models.h" #include "./utils.h" @@ -249,7 +252,19 @@ void GraphWidget::setupToolbar() [=](int /*index*/) { if (!project) return; - int res = resolutionPicker->currentData().toInt(); + const int previous = project->textureWidth; + const int res = resolutionPicker->currentData().toInt(); + if (res == previous) + return; + + if (!confirmResolutionChange(previous, res)) { + QSignalBlocker block(resolutionPicker); + const int back = resolutionPicker->findData(previous); + if (back >= 0) + resolutionPicker->setCurrentIndex(back); + return; + } + project->textureWidth = res; project->textureHeight = res; for (auto& node : project->nodes) @@ -272,6 +287,9 @@ void GraphWidget::setupToolbar() connect(seedInput, &QSpinBox::valueChanged, this, [=]() { if (!project) return; + Telemetry::breadcrumb("ui.seed", "random seed changed", + {{"seed", (int64_t)seedInput->value()}, + {"node_count", (int64_t)project->nodes.size()}}); project->randomSeed = seedInput->value(); for (auto& node : project->nodes) node->isDirty = true; @@ -476,10 +494,90 @@ void GraphWidget::dropEvent(QDropEvent* evt) } } +bool GraphWidget::confirmResolutionChange(int from, int to) +{ + const int nodeCount = project ? project->nodes.size() : 0; + const int64_t estimated = + TextureRenderer::estimatedNodeTextureBytes(to) * nodeCount; + const SystemInfo::GpuMemory mem = + renderer ? renderer->queryGpuMemory() : SystemInfo::GpuMemory{}; + + Telemetry::breadcrumb( + "ui.resolution", + std::to_string(from) + " -> " + std::to_string(to), + {{"from", (int64_t)from}, + {"to", (int64_t)to}, + {"node_count", (int64_t)nodeCount}, + {"estimated_mb", estimated / (1024 * 1024)}, + {"vram_available_mb", + mem.known ? mem.availableKb / 1024 : (int64_t)-1}}); + + // Only worth asking when the driver actually reports free VRAM and we're + // clearly over it. Where it doesn't (plenty of Mesa configurations), the + // rollback in TextureRenderer covers us instead of nagging on a guess. + const int64_t availableBytes = mem.availableKb * 1024; + if (!mem.known || estimated <= availableBytes * 7 / 10) + return true; + + const auto mb = [](int64_t bytes) { + return QString::number(bytes / (1024 * 1024)); + }; + + const auto choice = QMessageBox::warning( + this, tr("Not enough video memory?"), + tr("Rendering %1 nodes at %2 x %2 needs about %3 MB of video memory, " + "but your GPU reports only %4 MB free.\n\n" + "TextureLab will fall back to %5 x %5 if it runs out. Continue?") + .arg(nodeCount) + .arg(to) + .arg(mb(estimated)) + .arg(mb(availableBytes)) + .arg(from), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + + if (choice != QMessageBox::Yes) { + Telemetry::breadcrumb("ui.resolution", "resolution change declined by user", + {{"to", (int64_t)to}}); + return false; + } + + return true; +} + +void GraphWidget::onResolutionChangeFailed(int requested, int fallback) +{ + { + QSignalBlocker block(resolutionPicker); + const int index = resolutionPicker->findData(fallback); + if (index >= 0) + resolutionPicker->setCurrentIndex(index); + } + + QMessageBox::warning( + this, tr("Resolution change failed"), + tr("Your GPU ran out of memory allocating textures at %1 x %1.\n\n" + "The project has been returned to %2 x %2.") + .arg(requested) + .arg(fallback)); +} + void GraphWidget::setTextureRenderer(TextureRenderer* renderer) { this->renderer = renderer; + // MainWindow clears the renderer with a null before building the next one + // (setProject); connecting to it emits "invalid nullptr parameter" for + // every signal below. + if (!renderer) + return; + + // Queued: rollBackResolution() emits this from inside TextureRenderer's + // update loop, and onResolutionChangeFailed puts up a modal dialog. Letting + // that spin an event loop mid-update would re-enter update() through the + // renderer's queued nodeRendered callbacks. + connect(renderer, &TextureRenderer::resolutionChangeFailed, this, + &GraphWidget::onResolutionChangeFailed, Qt::QueuedConnection); + connect(renderer, &TextureRenderer::thumbnailGenerated, [=](const QString& nodeId, GLint texId, const QPixmap& pixmap) { // scene->setNodeThumbnail(nodeId, pixmap); diff --git a/src/texturelab/widgets/graphwidget.h b/src/texturelab/widgets/graphwidget.h index 5a21851b..10fa3313 100644 --- a/src/texturelab/widgets/graphwidget.h +++ b/src/texturelab/widgets/graphwidget.h @@ -72,6 +72,15 @@ class GraphWidget : public QMainWindow { private: void setupToolbar(); + // Breadcrumbs the change, and — when the driver tells us how much VRAM is + // free — asks first if the new resolution plausibly won't fit. Returns + // false if the user backed out. + bool confirmResolutionChange(int from, int to); + + // Puts the picker back and explains, after TextureRenderer gave up on a + // resolution and rolled the project back. + void onResolutionChangeFailed(int requested, int fallback); + NodeSearchPopup* searchPopup; QPoint lastMousePos; From ba1deacaded07f19a194b456c179fef5a0e1831b Mon Sep 17 00:00:00 2001 From: Nicolas Brown Date: Thu, 3 Sep 2026 11:10:29 -0500 Subject: [PATCH 164/164] fix number parsing --- src/texturelab/CMakeLists.txt | 1 + src/texturelab/clipboard.cpp | 29 ++++++------ src/texturelab/curve.cpp | 15 +++--- src/texturelab/jsonutils.h | 84 ++++++++++++++++++++++++++++++++++ src/texturelab/project.cpp | 17 ++++--- src/texturelab/props.h | 86 ++++++++++++++--------------------- tests/CMakeLists.txt | 6 +++ tests/tst_jsonutils.cpp | 80 ++++++++++++++++++++++++++++++++ 8 files changed, 237 insertions(+), 81 deletions(-) create mode 100644 src/texturelab/jsonutils.h create mode 100644 tests/tst_jsonutils.cpp diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 3979e649..1c0de985 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -181,6 +181,7 @@ set(PROJECT_SOURCES ./exporter.h ./exporter.cpp ./utils.h + ./jsonutils.h ./curve.h ./curve.cpp ./models.h diff --git a/src/texturelab/clipboard.cpp b/src/texturelab/clipboard.cpp index cb55537f..1e4bda26 100644 --- a/src/texturelab/clipboard.cpp +++ b/src/texturelab/clipboard.cpp @@ -1,4 +1,5 @@ #include "clipboard.h" +#include "jsonutils.h" #include "libraries/library.h" #include "props.h" #include @@ -132,17 +133,17 @@ bool Clipboard::pasteItems(TextureProjectPtr project, for (auto item : root["nodes"].toArray()) { auto o = item.toObject(); - expandBBox(o["x"].toDouble(), o["y"].toDouble()); + expandBBox(jsonutils::getDouble(o["x"]), jsonutils::getDouble(o["y"])); } for (auto item : root["comments"].toArray()) { auto o = item.toObject(); - expandBBox(o["x"].toDouble(), o["y"].toDouble()); + expandBBox(jsonutils::getDouble(o["x"]), jsonutils::getDouble(o["y"])); } for (auto item : root["frames"].toArray()) { auto o = item.toObject(); - expandBBox(o["x"].toDouble(), o["y"].toDouble()); - expandBBox(o["x"].toDouble() + o["width"].toDouble(), - o["y"].toDouble() + o["height"].toDouble()); + expandBBox(jsonutils::getDouble(o["x"]), jsonutils::getDouble(o["y"])); + expandBBox(jsonutils::getDouble(o["x"]) + jsonutils::getDouble(o["width"]), + jsonutils::getDouble(o["y"]) + jsonutils::getDouble(o["height"])); } // If nothing in the bbox (empty clipboard somehow), fall back to no shift @@ -170,9 +171,9 @@ bool Clipboard::pasteItems(TextureProjectPtr project, node->id = nodeIdMap[obj["id"].toString()]; node->exportName = obj["exportName"].toString(); - node->randomSeed = (long)obj["randomSeed"].toDouble(0); - node->pos = QVector2D((float)(obj["x"].toDouble() + offsetX), - (float)(obj["y"].toDouble() + offsetY)); + node->randomSeed = jsonutils::getLong(obj["randomSeed"]); + node->pos = QVector2D((float)(jsonutils::getDouble(obj["x"]) + offsetX), + (float)(jsonutils::getDouble(obj["y"]) + offsetY)); auto propObj = obj["properties"].toObject(); for (auto key : propObj.keys()) { @@ -217,8 +218,8 @@ bool Clipboard::pasteItems(TextureProjectPtr project, auto comment = CommentPtr(new Comment()); comment->id = QUuid::createUuid().toString(QUuid::WithoutBraces); comment->text = obj["text"].toString(); - comment->pos = QVector2D((float)(obj["x"].toDouble() + offsetX), - (float)(obj["y"].toDouble() + offsetY)); + comment->pos = QVector2D((float)(jsonutils::getDouble(obj["x"]) + offsetX), + (float)(jsonutils::getDouble(obj["y"]) + offsetY)); outComments.append(comment); } @@ -229,10 +230,10 @@ bool Clipboard::pasteItems(TextureProjectPtr project, frame->id = QUuid::createUuid().toString(QUuid::WithoutBraces); frame->text = obj["title"].toString(); frame->color = QColor(obj["color"].toString()); - frame->pos = QVector2D((float)(obj["x"].toDouble() + offsetX), - (float)(obj["y"].toDouble() + offsetY)); - frame->size = QVector2D((float)obj["width"].toDouble(), - (float)obj["height"].toDouble()); + frame->pos = QVector2D((float)(jsonutils::getDouble(obj["x"]) + offsetX), + (float)(jsonutils::getDouble(obj["y"]) + offsetY)); + frame->size = QVector2D(jsonutils::getFloat(obj["width"], 300), + jsonutils::getFloat(obj["height"], 200)); outFrames.append(frame); } diff --git a/src/texturelab/curve.cpp b/src/texturelab/curve.cpp index 9df6f5c9..7736f22a 100644 --- a/src/texturelab/curve.cpp +++ b/src/texturelab/curve.cpp @@ -1,5 +1,6 @@ #include "curve.h" +#include "jsonutils.h" #include #include @@ -241,13 +242,13 @@ Curve Curve::fromJson(const QJsonObject& obj) for (const auto& val : arr) { auto o = val.toObject(); CurvePoint pt; - pt.x = clamp01((float)o["x"].toDouble()); - pt.y = clamp01((float)o["y"].toDouble()); - pt.lx = qMin((float)o["lx"].toDouble(), 0.0f); - pt.ly = (float)o["ly"].toDouble(); - pt.rx = qMax((float)o["rx"].toDouble(), 0.0f); - pt.ry = (float)o["ry"].toDouble(); - pt.smooth = o["smooth"].toBool(true); + pt.x = clamp01(jsonutils::getFloat(o["x"])); + pt.y = clamp01(jsonutils::getFloat(o["y"])); + pt.lx = qMin(jsonutils::getFloat(o["lx"]), 0.0f); + pt.ly = jsonutils::getFloat(o["ly"]); + pt.rx = qMax(jsonutils::getFloat(o["rx"]), 0.0f); + pt.ry = jsonutils::getFloat(o["ry"]); + pt.smooth = jsonutils::getBool(o["smooth"], true); curve.points.append(pt); } diff --git a/src/texturelab/jsonutils.h b/src/texturelab/jsonutils.h new file mode 100644 index 00000000..c8782f10 --- /dev/null +++ b/src/texturelab/jsonutils.h @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include + +// Older project files aren't strict about json types: numbers were sometimes +// written as strings ("1.5", "3"), ints as floats (3.0) and bools as +// "true"/"false". These helpers coerce whatever is in the json into the type +// the caller wants and fall back to defaultValue when the value is missing or +// can't be read as one. +namespace jsonutils { + +inline double getDouble(const QJsonValue& val, double defaultValue = 0.0) +{ + if (val.isDouble()) + return val.toDouble(); + + if (val.isString()) { + bool ok = false; + auto num = val.toString().trimmed().toDouble(&ok); + return ok ? num : defaultValue; + } + + if (val.isBool()) + return val.toBool() ? 1.0 : 0.0; + + return defaultValue; +} + +inline float getFloat(const QJsonValue& val, float defaultValue = 0.0f) +{ + return (float)getDouble(val, (double)defaultValue); +} + +inline long getLong(const QJsonValue& val, long defaultValue = 0) +{ + return (long)std::llround(getDouble(val, (double)defaultValue)); +} + +inline int getInt(const QJsonValue& val, int defaultValue = 0) +{ + return (int)getLong(val, (long)defaultValue); +} + +inline bool getBool(const QJsonValue& val, bool defaultValue = false) +{ + if (val.isBool()) + return val.toBool(); + + if (val.isDouble()) + return val.toDouble() != 0.0; + + if (val.isString()) { + auto str = val.toString().trimmed().toLower(); + if (str == "true" || str == "yes") + return true; + if (str == "false" || str == "no" || str.isEmpty()) + return false; + return getDouble(val, defaultValue ? 1.0 : 0.0) != 0.0; + } + + return defaultValue; +} + +inline QString getString(const QJsonValue& val, + const QString& defaultValue = QString()) +{ + if (val.isString()) + return val.toString(); + + if (val.isDouble()) { + auto num = val.toDouble(); + return num == std::floor(num) ? QString::number((qlonglong)num) + : QString::number(num); + } + + if (val.isBool()) + return val.toBool() ? QStringLiteral("true") : QStringLiteral("false"); + + return defaultValue; +} + +} // namespace jsonutils diff --git a/src/texturelab/project.cpp b/src/texturelab/project.cpp index 752797dd..a7d16fa2 100644 --- a/src/texturelab/project.cpp +++ b/src/texturelab/project.cpp @@ -1,6 +1,7 @@ #include "project.h" #include "libraries/library.h" #include "libraries/libraryversionmigrator.h" +#include "jsonutils.h" #include "libraries/libversion.h" #include "props.h" #include @@ -54,13 +55,13 @@ TextureProjectPtr Project::loadTextureFromJson(QJsonObject json) continue; node->exportName = nodeDef["exportName"].toString(""); node->id = nodeDef["id"].toString(); - node->randomSeed = (long)nodeDef["randomSeed"].toDouble(0); + node->randomSeed = jsonutils::getLong(nodeDef["randomSeed"]); // get position from scene // we're converging the scene and designer props into one auto sceneObj = sceneNodesObj[node->id].toObject(); - auto x = sceneObj["x"].toDouble(); - auto y = sceneObj["y"].toDouble(); + auto x = jsonutils::getFloat(sceneObj["x"]); + auto y = jsonutils::getFloat(sceneObj["y"]); node->pos = QVector2D(x, y); // add props @@ -113,7 +114,8 @@ TextureProjectPtr Project::loadTextureFromJson(QJsonObject json) ? QUuid::createUuid().toString(QUuid::WithoutBraces) : commentId; comment->text = obj["text"].toString(); - comment->pos = QVector2D(obj["x"].toDouble(), obj["y"].toDouble()); + comment->pos = QVector2D(jsonutils::getFloat(obj["x"]), + jsonutils::getFloat(obj["y"])); texture->comments[comment->id] = comment; } @@ -127,9 +129,10 @@ TextureProjectPtr Project::loadTextureFromJson(QJsonObject json) ? QUuid::createUuid().toString(QUuid::WithoutBraces) : frameId; frame->text = obj["title"].toString(); - frame->pos = QVector2D(obj["x"].toDouble(), obj["y"].toDouble()); - frame->size = - QVector2D(obj["width"].toDouble(300), obj["height"].toDouble(200)); + frame->pos = QVector2D(jsonutils::getFloat(obj["x"]), + jsonutils::getFloat(obj["y"])); + frame->size = QVector2D(jsonutils::getFloat(obj["width"], 300), + jsonutils::getFloat(obj["height"], 200)); auto colorStr = obj["color"].toString(); if (!colorStr.isEmpty()) frame->color = QColor(colorStr); diff --git a/src/texturelab/props.h b/src/texturelab/props.h index dd7fb098..50be9911 100644 --- a/src/texturelab/props.h +++ b/src/texturelab/props.h @@ -2,6 +2,7 @@ #include "../colorpicker/gradient.h" #include "curve.h" +#include "jsonutils.h" #include #include #include @@ -119,22 +120,17 @@ class FloatProp : public Prop { void fromJson(const QJsonObject& obj) override { Prop::fromJson(obj); - value = obj["value"].toDouble(); - minValue = obj["minValue"].toDouble(); - maxValue = obj["maxValue"].toDouble(); - step = obj["step"].toDouble(); + value = jsonutils::getDouble(obj["value"], value); + minValue = jsonutils::getDouble(obj["minValue"], minValue); + maxValue = jsonutils::getDouble(obj["maxValue"], maxValue); + step = jsonutils::getDouble(obj["step"], step); } QJsonValue toJsonValue() override { return value; } void fromJsonValue(const QJsonValue& val) override { - if (val.isString()) { - value = val.toString().toDouble(); - } - else { - value = val.toDouble(); - } + value = jsonutils::getDouble(val, value); } }; @@ -178,22 +174,17 @@ class IntProp : public Prop { void fromJson(const QJsonObject& obj) override { Prop::fromJson(obj); - value = obj["value"].toDouble(); - minValue = obj["minValue"].toDouble(); - maxValue = obj["maxValue"].toDouble(); - step = obj["step"].toDouble(); + value = jsonutils::getLong(obj["value"], value); + minValue = jsonutils::getLong(obj["minValue"], minValue); + maxValue = jsonutils::getLong(obj["maxValue"], maxValue); + step = jsonutils::getLong(obj["step"], step); } QJsonValue toJsonValue() override { return (qlonglong)value; } void fromJsonValue(const QJsonValue& val) override { - if (val.isString()) { - value = (long)val.toString().toDouble(); - } - else { - value = (long)val.toDouble(); - } + value = jsonutils::getLong(val, value); } }; @@ -228,20 +219,14 @@ class BoolProp : public Prop { void fromJson(const QJsonObject& obj) override { Prop::fromJson(obj); - value = obj["value"].toBool(); + value = jsonutils::getBool(obj["value"], value); } QJsonValue toJsonValue() override { return value; } void fromJsonValue(const QJsonValue& val) override { - if (val.isString()) { - QString str = val.toString().toLower(); - value = (str == "true" || str == "1"); - } - else { - value = val.toBool(); - } + value = jsonutils::getBool(val, value); } }; @@ -285,7 +270,7 @@ class EnumProp : public Prop { void fromJson(const QJsonObject& obj) override { Prop::fromJson(obj); - index = obj["index"].toInt(); + index = jsonutils::getInt(obj["index"], index); auto list = obj["values"].toArray(); values.clear(); @@ -298,12 +283,7 @@ class EnumProp : public Prop { void fromJsonValue(const QJsonValue& val) override { - if (val.isString()) { - index = (long)val.toString().toDouble(); - } - else { - index = (long)val.toDouble(); - } + index = jsonutils::getInt(val, index); } }; @@ -340,10 +320,10 @@ struct ColorProp : public Prop { { Prop::fromJson(obj); auto colorObj = obj["value"].toObject(); - value.setRedF(colorObj["r"].toDouble()); - value.setGreenF(colorObj["g"].toDouble()); - value.setBlueF(colorObj["b"].toDouble()); - value.setAlphaF(colorObj["a"].toDouble()); + value.setRedF(jsonutils::getFloat(colorObj["r"])); + value.setGreenF(jsonutils::getFloat(colorObj["g"])); + value.setBlueF(jsonutils::getFloat(colorObj["b"])); + value.setAlphaF(jsonutils::getFloat(colorObj["a"], 1.0f)); } QJsonValue toJsonValue() override @@ -359,10 +339,10 @@ struct ColorProp : public Prop { void fromJsonValue(const QJsonValue& val) override { auto colorObj = val.toObject(); - value.setRedF(colorObj["r"].toDouble()); - value.setGreenF(colorObj["g"].toDouble()); - value.setBlueF(colorObj["b"].toDouble()); - value.setAlphaF(colorObj["a"].toDouble()); + value.setRedF(jsonutils::getFloat(colorObj["r"])); + value.setGreenF(jsonutils::getFloat(colorObj["g"])); + value.setBlueF(jsonutils::getFloat(colorObj["b"])); + value.setAlphaF(jsonutils::getFloat(colorObj["a"], 1.0f)); } }; @@ -456,13 +436,13 @@ class GradientProp : public Prop { for (const auto& pointValue : pointsArray) { auto pointObj = pointValue.toObject(); - float position = pointObj["t"].toDouble(); + float position = jsonutils::getFloat(pointObj["t"]); auto colorObj = pointObj["color"].toObject(); QColor color; - color.setRedF(colorObj["r"].toDouble()); - color.setGreenF(colorObj["g"].toDouble()); - color.setBlueF(colorObj["b"].toDouble()); - color.setAlphaF(colorObj["a"].toDouble()); + color.setRedF(jsonutils::getFloat(colorObj["r"])); + color.setGreenF(jsonutils::getFloat(colorObj["g"])); + color.setBlueF(jsonutils::getFloat(colorObj["b"])); + color.setAlphaF(jsonutils::getFloat(colorObj["a"], 1.0f)); value.addPoint(GradientPoint(position, color)); } @@ -495,13 +475,13 @@ class GradientProp : public Prop { for (const auto& pointValue : pointsArray) { auto pointObj = pointValue.toObject(); - float position = pointObj["t"].toDouble(); + float position = jsonutils::getFloat(pointObj["t"]); auto colorObj = pointObj["color"].toObject(); QColor color; - color.setRedF(colorObj["r"].toDouble()); - color.setGreenF(colorObj["g"].toDouble()); - color.setBlueF(colorObj["b"].toDouble()); - color.setAlphaF(colorObj["a"].toDouble()); + color.setRedF(jsonutils::getFloat(colorObj["r"])); + color.setGreenF(jsonutils::getFloat(colorObj["g"])); + color.setBlueF(jsonutils::getFloat(colorObj["b"])); + color.setAlphaF(jsonutils::getFloat(colorObj["a"], 1.0f)); value.addPoint(GradientPoint(position, color)); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b8d08e45..83a1ff3f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -55,3 +55,9 @@ target_sources(tst_texturelistmodel PRIVATE target_include_directories(tst_texturelistmodel PRIVATE ${CMAKE_SOURCE_DIR}/src/texturelab/launcher) target_link_libraries(tst_texturelistmodel PRIVATE Qt${QT_VERSION_MAJOR}::Gui) + +# Loose types in old project files: numbers as strings, ints as floats. +# Header-only, so the test just includes it. +texturelab_add_test(tst_jsonutils) +target_include_directories(tst_jsonutils PRIVATE + ${CMAKE_SOURCE_DIR}/src/texturelab) diff --git a/tests/tst_jsonutils.cpp b/tests/tst_jsonutils.cpp new file mode 100644 index 00000000..630f921d --- /dev/null +++ b/tests/tst_jsonutils.cpp @@ -0,0 +1,80 @@ +#include "jsonutils.h" + +#include + +using namespace jsonutils; + +class TestJsonUtils : public QObject { + Q_OBJECT + +private slots: + void readsPlainNumbers(); + void readsNumbersStoredAsStrings(); + void roundsFloatsStoredForInts(); + void readsBoolsInEveryFormThatWasWritten(); + void readsNumbersStoredAsStringsForBools(); + void stringifiesNumbers(); + void fallsBackWhenMissingOrJunk(); +}; + +void TestJsonUtils::readsPlainNumbers() +{ + QCOMPARE(getDouble(QJsonValue(1.5)), 1.5); + QCOMPARE(getFloat(QJsonValue(1.5)), 1.5f); + QCOMPARE(getInt(QJsonValue(3)), 3); + QCOMPARE(getLong(QJsonValue(2147483648.0)), 2147483648L); +} + +void TestJsonUtils::readsNumbersStoredAsStrings() +{ + // Old files wrote prop values through JS string conversion. + QCOMPARE(getDouble(QJsonValue(QStringLiteral("1.5"))), 1.5); + QCOMPARE(getDouble(QJsonValue(QStringLiteral(" -0.25 "))), -0.25); + QCOMPARE(getInt(QJsonValue(QStringLiteral("7"))), 7); + QCOMPARE(getFloat(QJsonValue(QStringLiteral("1e2"))), 100.0f); +} + +void TestJsonUtils::roundsFloatsStoredForInts() +{ + // An int prop saved as 3.0 (or 2.7 after a slider drag) must not truncate + // to something a step below what the user set. + QCOMPARE(getInt(QJsonValue(3.0)), 3); + QCOMPARE(getInt(QJsonValue(2.7)), 3); + QCOMPARE(getInt(QJsonValue(QStringLiteral("2.7"))), 3); + QCOMPARE(getInt(QJsonValue(-2.7)), -3); +} + +void TestJsonUtils::readsBoolsInEveryFormThatWasWritten() +{ + QCOMPARE(getBool(QJsonValue(true)), true); + // BoolProp::toJson still writes the string form. + QCOMPARE(getBool(QJsonValue(QStringLiteral("true"))), true); + QCOMPARE(getBool(QJsonValue(QStringLiteral("TRUE"))), true); + QCOMPARE(getBool(QJsonValue(QStringLiteral("false"))), false); + QCOMPARE(getBool(QJsonValue(1.0)), true); + QCOMPARE(getBool(QJsonValue(0.0)), false); +} + +void TestJsonUtils::readsNumbersStoredAsStringsForBools() +{ + QCOMPARE(getBool(QJsonValue(QStringLiteral("1"))), true); + QCOMPARE(getBool(QJsonValue(QStringLiteral("0"))), false); +} + +void TestJsonUtils::stringifiesNumbers() +{ + QCOMPARE(getString(QJsonValue(3.0)), QStringLiteral("3")); + QCOMPARE(getString(QJsonValue(QStringLiteral("abc"))), QStringLiteral("abc")); +} + +void TestJsonUtils::fallsBackWhenMissingOrJunk() +{ + QJsonObject obj; + QCOMPARE(getDouble(obj["missing"], 300.0), 300.0); + QCOMPARE(getFloat(QJsonValue(QStringLiteral("not a number")), 2.5f), 2.5f); + QCOMPARE(getBool(QJsonValue(QJsonValue::Null), true), true); + QCOMPARE(getInt(QJsonValue(QJsonArray()), 9), 9); +} + +QTEST_APPLESS_MAIN(TestJsonUtils) +#include "tst_jsonutils.moc"