diff --git a/docs/rv-manuals/rv-reference-manual/rv-reference-manual-chapter-nine.md b/docs/rv-manuals/rv-reference-manual/rv-reference-manual-chapter-nine.md index ee88e73d7..f0a8b641a 100644 --- a/docs/rv-manuals/rv-reference-manual/rv-reference-manual-chapter-nine.md +++ b/docs/rv-manuals/rv-reference-manual/rv-reference-manual-chapter-nine.md @@ -172,6 +172,7 @@ The PACKAGE file is a [YAML](http://www.yaml.org/) file providing information ab | rv | version number | • | The minimum version of commercial RV which this package is compatible with | | openrv | version number | • | The minimum version of Open RV which this package is compatible with | | requires | zip file name list | | Any other packages (as zip file names) which are required in order to install/load this package | +| excludes | package base name list | | Any other packages (as base names, separated by spaces) which cannot be loaded at the same time as this package. Making this package loadable in the package manager will unload any excluded package that is currently loaded. The exclusion is mutual, so only one of the two packages needs to declare it | | icon | PNG file name | | The name of an file with an icon for this package | | imageio | file list | | List of files in package which implement Image I/O | | movieio | file list | | List of files in package which implement Movie I/O | diff --git a/src/lib/app/RvCommon/RvBottomViewToolBar.cpp b/src/lib/app/RvCommon/RvBottomViewToolBar.cpp index 69b58a8f6..bf8f1a653 100644 --- a/src/lib/app/RvCommon/RvBottomViewToolBar.cpp +++ b/src/lib/app/RvCommon/RvBottomViewToolBar.cpp @@ -491,7 +491,7 @@ namespace Rv void RvBottomViewToolBar::smActionTriggered(bool b) { m_session->userGenericEvent("mode-manager-toggle-mode", "session_manager"); } - void RvBottomViewToolBar::paintActionTriggered(bool) { m_session->userGenericEvent("mode-manager-toggle-mode", "annotate_mode"); } + void RvBottomViewToolBar::paintActionTriggered(bool) { m_session->userGenericEvent("toggle-annotate-toolbar", ""); } void RvBottomViewToolBar::infoActionTriggered(bool) { m_session->userGenericEvent("toggle-hud-info-widget", ""); } diff --git a/src/lib/app/RvPackage/PackageManager.cpp b/src/lib/app/RvPackage/PackageManager.cpp index e673f1afe..952d48606 100644 --- a/src/lib/app/RvPackage/PackageManager.cpp +++ b/src/lib/app/RvPackage/PackageManager.cpp @@ -265,6 +265,11 @@ namespace Rv } } + for (const int exclusion : package.exclusions) + { + allowLoading(m_packages[exclusion], false, depth + 1); + } + m_doNotLoadPackages.removeOne(package.file); if (package.optional && !m_optLoadPackages.contains(package.file)) @@ -1301,6 +1306,8 @@ namespace Rv package.contact = v; else if (pname == "version") package.version = v; + else if (pname == "excludes") + package.excludes = v; else if (pname == "requires") package. requires @@ -1546,7 +1553,7 @@ namespace Rv void PackageManager::findPackageDependencies() { - for (size_t i = 0; i < m_packages.size(); i++) + for (int i = 0; i < m_packages.size(); i++) { Package& package = m_packages[i]; QStringList deps = package. @@ -1565,6 +1572,28 @@ namespace Rv package.compatible = false; } } + + const auto excludedPackages = package.excludes.split(" ", Qt::SkipEmptyParts); + + for (const auto& excludedPackage : excludedPackages) + { + const int packageIndex = findPackageIndexByZip(excludedPackage); + + if (packageIndex == -1 || packageIndex == i) + { + continue; + } + + if (!package.exclusions.contains(packageIndex)) + { + package.exclusions.push_back(packageIndex); + } + + if (!m_packages[packageIndex].exclusions.contains(i)) + { + m_packages[packageIndex].exclusions.push_back(i); + } + } } } @@ -1873,6 +1902,7 @@ namespace Rv { m_packages[q].usedBy.clear(); m_packages[q].uses.clear(); + m_packages[q].exclusions.clear(); } findPackageDependencies(); diff --git a/src/lib/app/RvPackage/RvPackage/PackageManager.h b/src/lib/app/RvPackage/RvPackage/PackageManager.h index e293acfc9..9456ddb97 100644 --- a/src/lib/app/RvPackage/RvPackage/PackageManager.h +++ b/src/lib/app/RvPackage/RvPackage/PackageManager.h @@ -116,6 +116,7 @@ namespace Rv QString version; QString url; QString icon; + QString excludes; QString requires; QStringList imageio; @@ -143,6 +144,7 @@ namespace Rv QList uses; QList usedBy; + QList exclusions; }; struct ModeEntry diff --git a/src/lib/app/mu_rvui/mode_manager.mu b/src/lib/app/mu_rvui/mode_manager.mu index 001e736c1..4ad638d90 100644 --- a/src/lib/app/mu_rvui/mode_manager.mu +++ b/src/lib/app/mu_rvui/mode_manager.mu @@ -217,7 +217,7 @@ class: ModeManagerMode : MinorMode // Note: We only return the disabled state here. The "category-event-blocked" event // is sent by toggleModeEntry() when the user actually tries to invoke the action, // not when just checking if the menu item should be disabled. - if (!commands.isEventCategoryEnabled("annotate_category") && entry.name == "annotate_mode") + if (!commands.isEventCategoryEnabled("annotate_category") && (entry.name == "annotate_mode" || entry.name == "annotate_toolbar_mode")) { return DisabledMenuState; } @@ -439,7 +439,7 @@ class: ModeManagerMode : MinorMode \: toggleModeEntry (void; Event event, ModeEntry entry, ModeManagerMode mm) { // Special handling of mode toggling if certain categories of actions are disabled. - if (!commands.isEventCategoryEnabled("annotate_category") && entry.name == "annotate_mode") + if (!commands.isEventCategoryEnabled("annotate_category") && (entry.name == "annotate_mode" || entry.name == "annotate_toolbar_mode")) { sendInternalEvent("category-event-blocked", "annotate_category"); return; @@ -698,16 +698,29 @@ class: ModeManagerMode : MinorMode Menu top; - for_each (m; _modes) + for_each (mode; _modes) { - if (m.menu neq nil) top.push_back(parseMenuEntry(m)); + if (mode.menu neq nil) + { + top.push_back(parseMenuEntry(mode)); + } - if (m.event neq nil) + if (mode.event neq nil) { - bind(m.event, \: (void; Event ev) + if (mode.name == "annotate_mode" || mode.name == "annotate_toolbar_mode") + { + bind("toggle-annotate-toolbar", \: (void; Event event) + { + toggleModeEntry(event, mode, this); + }); + } + else { - toggleModeEntry(ev, m, this); - }); + bind(mode.event, \: (void; Event event) + { + toggleModeEntry(event, mode, this); + }); + } } } diff --git a/src/lib/ip/IPBaseNodes/PaintIPNode.cpp b/src/lib/ip/IPBaseNodes/PaintIPNode.cpp index 2c7f25867..7f84ce693 100644 --- a/src/lib/ip/IPBaseNodes/PaintIPNode.cpp +++ b/src/lib/ip/IPBaseNodes/PaintIPNode.cpp @@ -4,7 +4,6 @@ // SPDX-License-Identifier: Apache-2.0 // #include -#include #include #include #include @@ -335,6 +334,10 @@ namespace IPCore const IntProperty* startFrameP = c->property("startFrame"); const IntProperty* durationP = c->property("duration"); + const FloatProperty* hardnessP = c->property("hardness"); + const StringProperty* tipTextureP = c->property("tipTexture"); + const IntProperty* blendModeP = c->property("blendMode"); + const float width = widthP && widthP->size() ? widthP->front() : 0.01f; const Vec4f color = colorP && colorP->size() ? colorP->front() : Vec4f(1.0, 1.0, 1.0, 1.0); const string brush = brushP && brushP->size() ? brushP->front() : string("circle"); @@ -349,6 +352,10 @@ namespace IPCore const int startFrame = (startFrameP != nullptr && startFrameP->size() != 0) ? startFrameP->front() : getStartFrame(*c); const int duration = (durationP != nullptr && durationP->size() != 0) ? durationP->front() : 1; + const float hardness = hardnessP && hardnessP->size() ? hardnessP->front() : 100.0f; + const string tipTexture = tipTextureP && tipTextureP->size() ? tipTextureP->front() : string(); + const unsigned int blendMode = blendModeP && blendModeP->size() ? blendModeP->front() : Paint::PolyLine::BlendNormal; + p.width = width; p.color = color; p.brush = brush; @@ -362,9 +369,14 @@ namespace IPCore p.eye = eye; p.startFrame = startFrame; p.duration = duration; - - // Classify the brush type once — drives all downstream data paths. - const bool isStampBrush = Paint::BrushTextureManager::instance().get(brush).isStamp; + p.hardness = hardness; + p.tipTexture = tipTexture; + p.stampBlendMode = (Paint::PolyLine::StampBlendMode)blendMode; + + // Classify the brush type once — drives all downstream data paths. The + // legacy "circle"/"gauss" names stay on the ribbon (non-stamp) path; + // everything else (including future UI-driven brushes) is stamp-based. + const bool isStampBrush = (brush != "circle" && brush != "gauss"); // Per-point widths are present when widthP has one entry per point. // Allow widthP to lag pointsP by one: the Mu layer inserts the point and diff --git a/src/lib/ip/IPCore/BrushTextureManager.cpp b/src/lib/ip/IPCore/BrushTextureManager.cpp index 15cf73670..4de7365e6 100644 --- a/src/lib/ip/IPCore/BrushTextureManager.cpp +++ b/src/lib/ip/IPCore/BrushTextureManager.cpp @@ -6,15 +6,14 @@ #include #include #include -#include -#include -#include -#include +#include +#include +#include +#include #include #include #include #include -#include #include namespace IPCore @@ -24,17 +23,6 @@ namespace IPCore // ── helpers ─────────────────────────────────────────────────────────────────── - static PolyLine::StampBlendMode blendFromString(const QString& s) - { - if (s == "additive") - return PolyLine::BlendAdditive; - if (s == "marker") - return PolyLine::BlendMarker; - if (s == "eraser") - return PolyLine::BlendEraser; - return PolyLine::BlendNormal; - } - /// Load an 8-bit grayscale PNG and upload it as a GL_LUMINANCE texture. /// Returns the GL texture name, or 0 on failure. static unsigned int uploadGrayscalePng(const std::string& path) @@ -117,7 +105,7 @@ namespace IPCore return s; } - std::string BrushTextureManager::catalogueDir() + std::string BrushTextureManager::tipDir() { if (const char* env = std::getenv("RV_BRUSH_DIR")) return env; @@ -133,83 +121,46 @@ namespace IPCore return ""; } - void BrushTextureManager::parseCatalogue() + unsigned int BrushTextureManager::getTexture(const std::string& tipFile) { - std::call_once(m_parseOnce, - [this]() - { - const QString qdir = QString::fromStdString(catalogueDir()); - const QString catPath = qdir + "/catalogue.json"; - - QFile f(catPath); - if (!f.open(QIODevice::ReadOnly)) - { - std::cerr << "[BrushTextureManager] catalogue not found: " << catPath.toStdString() << "\n"; - return; - } - - QJsonParseError err; - const QJsonDocument doc = QJsonDocument::fromJson(f.readAll(), &err); - if (doc.isNull()) - { - std::cerr << "[BrushTextureManager] JSON parse error: " << err.errorString().toStdString() << "\n"; - return; - } - - for (const QJsonValue& v : doc.object().value("brushes").toArray()) - { - const QJsonObject entry = v.toObject(); - const std::string name = entry.value("name").toString().toStdString(); - const QString tip = entry.value("tip").toString(); - const QString blend = entry.value("blend").toString("normal"); - const bool soft = entry.value("soft").toBool(false); - - BrushInfo& info = m_brushes[name]; - info.blendMode = blendFromString(blend); - info.softShader = soft; - info.isStamp = !tip.isEmpty(); - info.tipFile = tip.toStdString(); - } - }); // call_once - } + if (tipFile.empty()) + return 0; - void BrushTextureManager::load() - { - if (m_texturesLoaded) - return; - m_texturesLoaded = true; + std::lock_guard lock(m_mutex); + const auto it = m_textures.find(tipFile); + if (it != m_textures.end()) + return it->second; - parseCatalogue(); + const QString path = QString::fromStdString(tipDir()) + "/" + QString::fromStdString(tipFile); + const unsigned int texId = uploadGrayscalePng(path.toStdString()); + if (!texId) + std::cerr << "[BrushTextureManager] failed to load tip: " << path.toStdString() << "\n"; - const QString qdir = QString::fromStdString(catalogueDir()); - for (auto& kv : m_brushes) - { - if (kv.second.isStamp && !kv.second.tipFile.empty() && !kv.second.textureId) - { - const std::string tipPath = (qdir + "/" + QString::fromStdString(kv.second.tipFile)).toStdString(); - kv.second.textureId = uploadGrayscalePng(tipPath); - if (!kv.second.textureId) - std::cerr << "[BrushTextureManager] failed to load tip: " << tipPath << "\n"; - } - } + m_textures[tipFile] = texId; // cache failures too, so a bad path isn't retried every frame + return texId; } - BrushInfo BrushTextureManager::get(const std::string& name) + std::vector BrushTextureManager::listTipFiles() const { - parseCatalogue(); - const auto it = m_brushes.find(name); - return (it != m_brushes.end()) ? it->second : BrushInfo{}; + std::vector files; + const QDir dir(QString::fromStdString(tipDir())); + for (const QFileInfo& fi : dir.entryInfoList(QStringList() << "*.png", QDir::Files, QDir::Name)) + files.push_back(fi.fileName().toStdString()); + return files; } void BrushTextureManager::clear() { - for (auto& kv : m_brushes) + std::lock_guard lock(m_mutex); + for (auto& kv : m_textures) { - if (kv.second.textureId) - glDeleteTextures(1, &kv.second.textureId); - kv.second.textureId = 0; + if (kv.second) + { + GLuint texId = kv.second; + glDeleteTextures(1, &texId); + } } - m_texturesLoaded = false; + m_textures.clear(); } } // namespace Paint diff --git a/src/lib/ip/IPCore/IPCore/BrushTextureManager.h b/src/lib/ip/IPCore/IPCore/BrushTextureManager.h index 8577b79e9..c3e74ceb8 100644 --- a/src/lib/ip/IPCore/IPCore/BrushTextureManager.h +++ b/src/lib/ip/IPCore/IPCore/BrushTextureManager.h @@ -4,72 +4,61 @@ // SPDX-License-Identifier: Apache-2.0 // #pragma once -#include #include #include #include +#include namespace IPCore { namespace Paint { - /// Resolved properties for one catalogue brush entry. - struct BrushInfo - { - unsigned int textureId = 0; ///< GL texture name; 0 = procedural - PolyLine::StampBlendMode blendMode = PolyLine::BlendNormal; - bool softShader = false; ///< use soft Gaussian shader - bool isStamp = false; ///< true when the catalogue entry has a tip texture - std::string tipFile; ///< tip PNG filename relative to catalogue dir (empty = procedural) - }; - - /// Singleton that loads the brush catalogue JSON and uploads tip textures to GL. + /// Caches GL textures for brush tip PNGs, keyed by filename. + /// + /// Tip files live in a flat directory (RV_BRUSH_DIR env var, or + /// assets/brushes inside the application bundle) and are uploaded to GL + /// lazily on first use. Per-stroke brush behavior (hardness, blend mode, + /// which tip to use) comes from the stroke's own properties, set by the + /// UI — this class only resolves a tip filename to a GL texture. /// /// Usage: - /// // On the GL thread, once per application lifetime: - /// BrushTextureManager::instance().load(); + /// // On the GL thread, at render time: + /// unsigned int texId = BrushTextureManager::instance().getTexture(tipFile); /// - /// // At stroke-creation time (any thread): - /// BrushInfo info = BrushTextureManager::instance().get(brushName); + /// // For a UI tip picker, any thread, no GL required: + /// std::vector tips = BrushTextureManager::instance().listTipFiles(); /// class BrushTextureManager { public: static BrushTextureManager& instance(); - /// Upload tip PNGs as GL textures for any catalogue entries that have them. - /// Must be called on the active GL thread. Safe to call multiple times — - /// only the first call has any effect. - /// Catalogue metadata (isStamp, blendMode, softShader) is parsed lazily - /// on the first call to get(), so this need not be called first. - void load(); + /// Return the GL texture name for @p tipFile, uploading it from disk + /// on first request and caching the result (including failures, as 0). + /// Must be called on the active GL thread. Returns 0 if tipFile is + /// empty or the PNG can't be loaded. + unsigned int getTexture(const std::string& tipFile); - /// Return resolved info for @p name, or a default BrushInfo if not found. - /// Parses catalogue.json on the first call (any thread; no GL required). - BrushInfo get(const std::string& name); + /// List available tip PNG filenames in the brush tip directory, for + /// UI pickers. No GL required; safe to call from any thread. + std::vector listTipFiles() const; /// Release all GL textures. Call before GL context teardown. void clear(); - bool isLoaded() const { return m_texturesLoaded; } - - /// Resolve the brush catalogue directory: RV_BRUSH_DIR env var, or the - /// assets/brushes directory inside the application bundle (Contents/Resources on macOS). - static std::string catalogueDir(); + /// Resolve the brush tip directory: RV_BRUSH_DIR env var, or the + /// assets/brushes directory inside the application bundle + /// (Contents/Resources on macOS). + static std::string tipDir(); private: BrushTextureManager() = default; ~BrushTextureManager() { clear(); } - /// Parse catalogue.json and populate m_brushes metadata (no GL). - /// Thread-safe via m_parseOnce; idempotent after first call. - void parseCatalogue(); - - std::unordered_map m_brushes; - std::once_flag m_parseOnce; - bool m_texturesLoaded{false}; + std::mutex m_mutex; + std::unordered_map m_textures; }; } // namespace Paint diff --git a/src/lib/ip/IPCore/IPCore/PaintCommand.h b/src/lib/ip/IPCore/IPCore/PaintCommand.h index 888ce9440..258b40e48 100644 --- a/src/lib/ip/IPCore/IPCore/PaintCommand.h +++ b/src/lib/ip/IPCore/IPCore/PaintCommand.h @@ -177,8 +177,8 @@ namespace IPCore TessellateMode // each triangle can have its own color }; - // Blend mode for stamp brushes — resolved from the brush catalogue - // by BrushTextureManager and stored here so PaintCommand::execute() + // Blend mode for stamp brushes — set from the stroke's own "blendMode" + // property (see PaintIPNode::compilePenComponent) so PaintCommand::execute() // can set the correct glBlendFunc without inspecting the brush name. enum StampBlendMode { @@ -264,10 +264,10 @@ namespace IPCore int debug; bool ownPoints; - // Resolved from the brush catalogue at stroke-creation time. + // Stamp brush properties, set directly from the stroke's own properties StampBlendMode stampBlendMode{BlendNormal}; - bool stampSoftShader{false}; - unsigned int stampTexture{0}; ///< GL texture name; 0 = procedural + float hardness{100.0f}; ///< edge hardness 0-100; drives the `hardness` uniform in soft_stamp_frag.glsl + std::string tipTexture{}; ///< tip PNG filename resolved to a GL texture at render time; empty = procedural mutable HashValue idhash; diff --git a/src/lib/ip/IPCore/PaintCommand.cpp b/src/lib/ip/IPCore/PaintCommand.cpp index 66dd894c3..f0c3a2faf 100644 --- a/src/lib/ip/IPCore/PaintCommand.cpp +++ b/src/lib/ip/IPCore/PaintCommand.cpp @@ -21,6 +21,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -385,24 +388,32 @@ namespace IPCore if (isStamp) { - // Resolve brush properties at render time — the manager is - // guaranteed loaded by renderPaintCommands() before execute(). - const BrushInfo info = BrushTextureManager::instance().get(brush); + // Brush properties come straight from the stroke's own properties + // (see PaintIPNode::compilePenComponent) — resolve the tip texture, + // if any, at render time since that's the only place with a GL context. + const unsigned int textureId = tipTexture.empty() ? 0 : BrushTextureManager::instance().getTexture(tipTexture); - const GLProgram* stampProg = info.textureId ? texturePaintReplaceGLProgram() - : (info.softShader ? softPaintReplaceGLProgram() : paintReplaceGLProgram()); + const GLProgram* stampProg = textureId ? texturePaintReplaceGLProgram() : softPaintReplaceGLProgram(); GLPipeline* stampPipeline = glState->useGLProgram(stampProg); stampPipeline->setProjection(context.projMatrix); stampPipeline->setModelview(context.modelviewMatrix); stampPipeline->setViewport(0, 0, w, h); stampPipeline->setUniformFloat("uniformColor", 4, &(pcolor[0])); - if (info.textureId) + if (textureId) { int tipUnit = 1; stampPipeline->setUniformInt("brushTip", 1, &tipUnit); glActiveTexture(GL_TEXTURE0 + 1); - glBindTexture(GL_TEXTURE_2D, info.textureId); + glBindTexture(GL_TEXTURE_2D, textureId); + } + else + { + // Procedural (tip-less) brushes get a continuously variable + // edge from the `hardness` uniform instead of a hard cutover + // between two shaders — see soft_stamp_frag.glsl. + const float normalizedHardness = hardness / 100.0f; + stampPipeline->setUniformFloat("hardness", 1, (float*)&normalizedHardness); } if (context.hasStencil) @@ -417,7 +428,7 @@ namespace IPCore // BlendMarker and ghost mode both use two-pass isolation: GL_MAX into // a cleared currentFBO (prevents intra-stroke opacity accumulation), // then composite the background under the result via ONE_MINUS_DST_ALPHA. - if (info.blendMode == BlendMarker || isGhostOn) + if (stampBlendMode == BlendMarker || isGhostOn) { // Pass 1: stamps into cleared currentFBO @@ -453,7 +464,7 @@ namespace IPCore else { glEnable(GL_BLEND); - if (info.blendMode == BlendAdditive) + if (stampBlendMode == BlendAdditive) glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE, GL_ONE, GL_ONE); else glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA); @@ -1480,9 +1491,6 @@ namespace IPCore if (context.commands.empty()) return; - if (!BrushTextureManager::instance().isLoaded()) - BrushTextureManager::instance().load(); - // fbo contains the render of the current image, only used by erase // strokes const GLFBO* fbo = context.initialRender; diff --git a/src/plugins/rv-packages/annotate/PACKAGE b/src/plugins/rv-packages/annotate/PACKAGE index 49920d3f7..e48126da5 100644 --- a/src/plugins/rv-packages/annotate/PACKAGE +++ b/src/plugins/rv-packages/annotate/PACKAGE @@ -1,12 +1,12 @@ package: Annotation author: Autodesk, Inc. organization: Autodesk, Inc. -version: 1.21 +version: 1.22 rv: 2025 openrv: 3.0.0 requires: '' -system: true -hidden: true +optional: false +hidden: false modes: - file: annotate_mode.mu @@ -17,11 +17,14 @@ modes: description: | -

Annotation mode adds simple drawing with undo/redo per frame. The - drawings are stored in the session directly and can be rendered via rvio. +

Annotation mode adds simple drawing with undo/redo per frame. The + drawings are stored in the session directly and can be rendered via rvio.

- Annotation can be started via the Tools menu. A drawing palette will be +

Annotation can be started via the Tools menu. A drawing palette will be visible and can be optionally attached to the main RV window. The drawing palette consists of three sections: the tool mode, the current color and - opacity, and history actions. -

+ opacity, and history actions.

+ +

This is the default annotation package. A newer, Python-based "Annotation (Beta)" + package is also available as part of the hidden packages. The two cannot run + at the same time, so enabling this package automatically disables the other

diff --git a/src/plugins/rv-packages/annotate_beta/CMakeLists.txt b/src/plugins/rv-packages/annotate_beta/CMakeLists.txt new file mode 100644 index 000000000..8f66c4198 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/CMakeLists.txt @@ -0,0 +1,11 @@ +# +# Copyright (C) 2025 Autodesk, Inc. All Rights Reserved. +# +# SPDX-License-Identifier: Apache-2.0 +# + +SET(_target + "annotate_beta" +) + +RV_STAGE(TYPE "RVPKG" TARGET ${_target}) diff --git a/src/plugins/rv-packages/annotate_beta/PACKAGE b/src/plugins/rv-packages/annotate_beta/PACKAGE new file mode 100644 index 000000000..8ada1c578 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/PACKAGE @@ -0,0 +1,26 @@ +package: Annotation (Beta) +author: Autodesk, Inc. +organization: Autodesk, Inc. +version: 0.1 +rv: 2025.1 +openrv: 3.0.0 +optional: true +hidden: true +excludes: annotate + +modes: + - file: annotate_beta_mode.py + load: immediate + menu: Tools/Annotation + shortcut: 'F10' + event: 'key-down--f10' + load: delay + +description: | +

Beta Python-based annotation package that provides shapes + (rectangle, ellipse, arrow, line) and text annotation tools + in addition to brush and eraser tools.

+ +

This package is hidden and disabled by default in favor of the + legacy "Annotation" package. The two cannot run at the same time, so + enabling this package automatically disables the other.

diff --git a/src/plugins/rv-packages/annotate_beta/annotate_beta_colour_picker.py b/src/plugins/rv-packages/annotate_beta/annotate_beta_colour_picker.py new file mode 100644 index 000000000..f2608cd5a --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/annotate_beta_colour_picker.py @@ -0,0 +1,213 @@ +# Copyright (c) 2025 Autodesk, Inc. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +try: + from PySide2 import QtCore, QtWidgets, QtGui +except ImportError: + from PySide6 import QtCore, QtWidgets, QtGui + +_PRESETS = [ + "#FF3B30", # red + "#FF9500", # orange + "#FFCC00", # yellow + "#34C759", # green + "#32ADE6", # cyan + "#FFFFFF", # white +] + + +class _HSVGradientWidget(QtWidgets.QWidget): + """Saturation/value square. Hue is controlled via set_hue().""" + + value_changed = QtCore.Signal(float, float) # sat, val in [0, 1] + + def __init__(self, parent=None): + super().__init__(parent) + self._hue = 0.0 + self._sat = 1.0 + self._val = 1.0 + self.setFixedSize(160, 120) + self.setCursor(QtCore.Qt.CrossCursor) + + def set_hue(self, hue_f): + self._hue = max(0.0, min(1.0, hue_f)) + self.update() + + def set_sv(self, sat, val): + self._sat = sat + self._val = val + self.update() + + def paintEvent(self, event): + p = QtGui.QPainter(self) + p.setRenderHint(QtGui.QPainter.Antialiasing, False) + w, h = self.width(), self.height() + + full_hue = QtGui.QColor.fromHsvF(self._hue, 1.0, 1.0) + + hg = QtGui.QLinearGradient(0, 0, w, 0) + hg.setColorAt(0.0, QtGui.QColor(255, 255, 255)) + hg.setColorAt(1.0, full_hue) + p.fillRect(self.rect(), hg) + + vg = QtGui.QLinearGradient(0, 0, 0, h) + vg.setColorAt(0.0, QtGui.QColor(0, 0, 0, 0)) + vg.setColorAt(1.0, QtGui.QColor(0, 0, 0, 255)) + p.fillRect(self.rect(), vg) + + cx = int(self._sat * (w - 1)) + cy = int((1.0 - self._val) * (h - 1)) + p.setRenderHint(QtGui.QPainter.Antialiasing, True) + p.setPen(QtGui.QPen(QtGui.QColor(255, 255, 255, 220), 1.5)) + p.setBrush(QtCore.Qt.NoBrush) + p.drawEllipse(cx - 5, cy - 5, 10, 10) + + def _update_from_pos(self, pos): + w = max(1, self.width() - 1) + h = max(1, self.height() - 1) + self._sat = max(0.0, min(1.0, pos.x() / w)) + self._val = max(0.0, min(1.0, 1.0 - pos.y() / h)) + self.update() + self.value_changed.emit(self._sat, self._val) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._update_from_pos(event.pos()) + + def mouseMoveEvent(self, event): + if event.buttons() & QtCore.Qt.LeftButton: + self._update_from_pos(event.pos()) + + +class _HueSlider(QtWidgets.QWidget): + """Horizontal rainbow hue bar.""" + + hue_changed = QtCore.Signal(float) # [0, 1] + + def __init__(self, parent=None): + super().__init__(parent) + self._hue = 0.0 + self.setFixedHeight(16) + self.setCursor(QtCore.Qt.PointingHandCursor) + + def set_hue(self, hue_f): + self._hue = max(0.0, min(1.0, hue_f)) + self.update() + + def paintEvent(self, event): + p = QtGui.QPainter(self) + p.setRenderHint(QtGui.QPainter.Antialiasing, True) + + # Inset bar so the handle circle doesn't clip at edges + bar = self.rect().adjusted(6, 3, -6, -3) + + grad = QtGui.QLinearGradient(bar.left(), 0, bar.right(), 0) + for i in range(7): + grad.setColorAt(i / 6.0, QtGui.QColor.fromHsvF(i / 6.0, 1.0, 1.0)) + p.setPen(QtCore.Qt.NoPen) + p.setBrush(grad) + p.drawRoundedRect(bar, 3, 3) + + x = bar.left() + int(self._hue * bar.width()) + cy = self.height() // 2 + p.setPen(QtGui.QPen(QtGui.QColor(255, 255, 255, 200), 1.5)) + p.setBrush(QtGui.QColor.fromHsvF(self._hue, 1.0, 1.0)) + p.drawEllipse(x - 5, cy - 5, 10, 10) + + def _update_from_pos(self, x): + bar_left = 6 + bar_width = max(1, self.width() - 12) + self._hue = max(0.0, min(1.0, (x - bar_left) / bar_width)) + self.update() + self.hue_changed.emit(self._hue) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._update_from_pos(event.pos().x()) + + def mouseMoveEvent(self, event): + if event.buttons() & QtCore.Qt.LeftButton: + self._update_from_pos(event.pos().x()) + + +class _SwatchButton(QtWidgets.QAbstractButton): + """Small rounded square colour preset.""" + + clicked_colour = QtCore.Signal(QtGui.QColor) + + def __init__(self, colour, parent=None): + super().__init__(parent) + self._colour = colour + self.setFixedSize(22, 22) + self.setCursor(QtCore.Qt.PointingHandCursor) + self.setToolTip(colour.name().upper()) + + def paintEvent(self, event): + p = QtGui.QPainter(self) + p.setRenderHint(QtGui.QPainter.Antialiasing, True) + p.setPen(QtGui.QPen(QtGui.QColor("#555555"), 1)) + p.setBrush(self._colour) + p.drawRoundedRect(self.rect().adjusted(1, 1, -1, -1), 3, 3) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.clicked_colour.emit(self._colour) + + +class ColourPickerSection(QtWidgets.QWidget): + """Inline colour picker — shown/hidden inside the secondary panel.""" + + colour_changed = QtCore.Signal(QtGui.QColor) + + def __init__(self, parent=None): + super().__init__(parent) + self._colour = QtGui.QColor(255, 255, 255) + + lay = QtWidgets.QVBoxLayout(self) + lay.setContentsMargins(10, 10, 10, 10) + lay.setSpacing(8) + + self._sv = _HSVGradientWidget() + self._sv.value_changed.connect(self._on_sv_changed) + lay.addWidget(self._sv, alignment=QtCore.Qt.AlignHCenter) + + self._hue_bar = _HueSlider() + self._hue_bar.hue_changed.connect(self._on_hue_changed) + lay.addWidget(self._hue_bar) + + swatch_row = QtWidgets.QWidget() + swatch_row.setStyleSheet("QWidget { background: transparent; }") + srow = QtWidgets.QHBoxLayout(swatch_row) + srow.setContentsMargins(0, 0, 0, 0) + srow.setSpacing(4) + for hex_col in _PRESETS: + btn = _SwatchButton(QtGui.QColor(hex_col)) + btn.clicked_colour.connect(self._on_preset_clicked) + srow.addWidget(btn) + srow.addStretch() + lay.addWidget(swatch_row) + + def set_colour(self, colour): + """Sync all controls to colour without emitting colour_changed.""" + self._colour = QtGui.QColor(colour) + h, s, v, _ = self._colour.getHsvF() + if h < 0: + h = 0.0 + self._sv.set_hue(h) + self._sv.set_sv(s, v) + self._hue_bar.set_hue(h) + + # ------------------------------------------------------------------ + + def _on_hue_changed(self, hue_f): + self._sv.set_hue(hue_f) + self._colour = QtGui.QColor.fromHsvF(hue_f, self._sv._sat, self._sv._val) + self.colour_changed.emit(self._colour) + + def _on_sv_changed(self, sat, val): + self._colour = QtGui.QColor.fromHsvF(self._hue_bar._hue, sat, val) + self.colour_changed.emit(self._colour) + + def _on_preset_clicked(self, colour): + self.set_colour(colour) + self.colour_changed.emit(colour) diff --git a/src/plugins/rv-packages/annotate_beta/annotate_beta_engine.py b/src/plugins/rv-packages/annotate_beta/annotate_beta_engine.py new file mode 100644 index 000000000..f5492b671 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/annotate_beta_engine.py @@ -0,0 +1,1328 @@ +# Copyright (c) 2025 Autodesk, Inc. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import math +import os +import uuid + +from rv import commands + +from annotate_beta_widget import ( + TOOL_PEN, + TOOL_AIRBRUSH, + TOOL_ERASER, + TOOL_RECT, + TOOL_CIRCLE, + TOOL_ARROW, + TOOL_LINE, + TOOL_TEXT, +) + +_SHAPE_TOOLS = {TOOL_RECT, TOOL_CIRCLE, TOOL_ARROW, TOOL_LINE} +_DRAWING_TOOLS = _SHAPE_TOOLS | {TOOL_TEXT, TOOL_PEN, TOOL_AIRBRUSH, TOOL_ERASER} + +TABLE_NAME = "annotate_beta_shape" + +_PREFIX = { + TOOL_RECT: "rect", + TOOL_CIRCLE: "ellipse", + TOOL_ARROW: "arrow", + TOOL_LINE: "line", +} + +_SIZE_SCALE = 1.0 / 10000.0 + +# Pen/eraser width range matches the Mu annotate_mode penDrawMode (minSize/maxSize). +# Slider 1→100 maps linearly to 0.001→0.024 in normalized image-space units. +_PEN_WIDTH_MIN = 0.001 +_PEN_WIDTH_MAX = 0.024 +_PEN_SLIDER_MIN = 1 +_PEN_SLIDER_MAX = 100 + +_SIZE_MIN = 0.001 + +# Base WCS fractions for each font size tier (desired px at zoom=1 / 1080). +# Multiplied by _screen_scale() at draw time — identical to how stroke +# border_width uses _SIZE_SCALE * _screen_scale(). This gives: +# - constant initial screen size regardless of zoom (draw-time compensation) +# - existing text scales with the image as you zoom in (fixed WCS stored) +_FONT_SIZE_WCS_BASE = {"small": 24.0 / 1080.0, "medium": 48.0 / 1080.0, "large": 72.0 / 1080.0} + +# US keyboard shift-symbol mapping used for text input +_SHIFT_MAP = { + "1": "!", + "2": "@", + "3": "#", + "4": "$", + "5": "%", + "6": "^", + "7": "&", + "8": "*", + "9": "(", + "0": ")", + "-": "_", + "=": "+", + "[": "{", + "]": "}", + "\\": "|", + ";": ":", + "'": '"', + ",": "<", + ".": ">", + "/": "?", + "`": "~", +} + + +class AnnotateDrawEngine: + def __init__(self, mode): + self._mode = mode + + # Shape drag state + self._anchor = None + self._last_pei = None + self._current_shape = None + self._current_node = None + self._current_frame = None + self._shape_type = None + self._shape_active = False + self._shift_transition = False + self._constraint_angle = None + + # Text editing state + self._text_active = False + self._text_buffer = "" + self._text_node = None # full property path — None until first char typed + self._text_anchor = None # _Vec2 position recorded on pointer-push + self._text_paint_node = None + self._text_frame = None + + # Pen/eraser stroke state + self._pen_stroke = None # current stroke node name (set on push, cleared on release) + self._pen_paint_node = None + self._pen_frame = None + self._pen_stroke_width = 0.0 # constant width for the active stroke, used per-drag insert + # True while the physical eraser end of a Wacom stylus is in use; forces erase + # mode regardless of the selected tool. + self._stylus_erasing = False + + # Source image node name captured on each pointer-push event. + # Used by _screen_scale() so widths stored in image space remain + # screen-constant across different zoom levels and image aspect ratios. + self._current_source_name = None + + # Undo/redo stacks — each entry: (paint_node, frame, node_name) + # node_name is the full prop path prefix, e.g. "RVPaint_1.rect:3:42:host_123" + self._undo_stack = [] # list of (paint_node, frame, node_name) + self._redo_stack = [] + + # ------------------------------------------------------------------ + # Event table setup + # ------------------------------------------------------------------ + + def setup_event_table(self, mode): + mode.defineEventTable(TABLE_NAME, self.bindings) + mode.defineEventTableRegex(TABLE_NAME, self.regex_bindings) + + @property + def bindings(self): + return [ + # Shape pointer events + ("pointer-1--push", self.on_push, "Start shape/text"), + ("pointer-1--drag", self.on_drag, "Update shape"), + ("pointer-1--release", self.on_release, "Commit shape"), + ("pointer-1--shift--push", self.on_push_shift, "Start shape (constrained)"), + ("pointer-1--shift--drag", self.on_drag_shift, "Update shape (constrained)"), + ("pointer-1--shift--release", self.on_release_shift, "Commit shape (constrained)"), + ("stylus-pen--push", self.on_push, "Start (stylus)"), + ("stylus-pen--drag", self.on_drag, "Update (stylus)"), + ("stylus-pen--release", self.on_release, "Commit (stylus)"), + ("stylus-eraser--push", self.on_stylus_eraser_push, "Start (stylus eraser end)"), + ("stylus-eraser--drag", self.on_stylus_eraser_drag, "Draw (stylus eraser end)"), + ("stylus-eraser--release", self.on_stylus_eraser_release, "Commit (stylus eraser end)"), + # Shape shift-constraint tracking + ("key-down--shift--shift", self.on_shift_down, "Constrain shape"), + ("key-up--shift", self.on_shift_up, "Release constraint"), + # Text editing — explicit keys + ("key-down--backspace", self.on_text_backspace, "Delete char"), + ("key-down--delete", self.on_text_backspace, "Delete char"), + ("key-down--space", self.on_text_space, "Insert space"), + ("key-down--return", self.on_text_commit, "Commit text"), + ("key-down--enter", self.on_text_commit, "Commit text"), + ("key-down--keypad-enter", self.on_text_commit, "Commit text"), + ("key-down--escape", self.on_text_cancel, "Cancel text"), + # Commit when frame changes so text isn't left open during playback + ("frame-changed", self.on_frame_changed, "Commit text on frame change"), + ] + + @property + def regex_bindings(self): + return [ + (r"^key-down--.$", self.on_text_key, "Insert char"), + (r"^key-down--shift--.$", self.on_text_key, "Insert shifted char"), + (r"^key-up--.$", self.on_key_up, "Key up"), + (r"^key-up--shift--.$", self.on_key_up, "Key up (shift)"), + ] + + # ------------------------------------------------------------------ + # Public helpers called by the mode + # ------------------------------------------------------------------ + + def update_text_style(self): + """Update all font properties on the active text node. + + Called whenever the user changes family, size, bold, italic, or underline + while a text node is being edited so changes apply immediately. + """ + if not self._text_active or self._text_node is None: + return + try: + font_size = _FONT_SIZE_WCS_BASE.get(self._mode._font_size, 48.0 / 1080.0) * self._screen_scale() + font_weight = "bold" if self._mode._font_bold else "normal" + font_style = "italic" if self._mode._font_italic else "normal" + text_deco = "underline" if self._mode._font_underline else "none" + commands.setStringProperty(f"{self._text_node}.fontFamily", [self._mode._font_family], True) + commands.setFloatProperty(f"{self._text_node}.fontSize", [font_size], True) + commands.setStringProperty(f"{self._text_node}.fontWeight", [font_weight], True) + commands.setStringProperty(f"{self._text_node}.fontStyle", [font_style], True) + commands.setStringProperty(f"{self._text_node}.textDecoration", [text_deco], True) + commands.redraw() + except Exception as e: + print(f"[annotate_beta] update_text_style error: {e}") + + def commit_text_if_active(self): + if self._text_active: + self._commit_text() + + # ------------------------------------------------------------------ + # State helpers + # ------------------------------------------------------------------ + + def _is_shape_tool(self): + return self._mode._tool in _SHAPE_TOOLS + + def _screen_scale(self): + """Scale factor that makes image-space widths screen-constant. + + At zoom=1 (fit-to-window) for a 16:9 image returns 1.0. + At zoom=2 returns 0.5 — stored width halved so the rendered screen + width stays the same as at zoom=1. + Also accounts for image aspect ratio: a letterboxed 2.39:1 image has + a smaller proj_scale than 16:9, so this factor compensates to give the + same screen-pixel width for any image format. + """ + name = self._current_source_name + if not name: + return 1.0 + try: + view_h = commands.viewSize()[1] + p0 = commands.imageToEventSpace(name, (0.0, 0.0), True) + p1 = commands.imageToEventSpace(name, (0.0, 0.01), True) + px_per_unit = abs(p1[1] - p0[1]) / 0.01 + return view_h / max(px_per_unit, 0.1) + except Exception: + return 1.0 + + def _border_width(self): + scale = self._screen_scale() if getattr(self._mode, "_scale_brush", True) else 1.0 + return max(_SIZE_MIN, self._mode._size * _SIZE_SCALE * scale) + + def _colors(self): + c = self._mode._colour + alpha = self._mode._opacity / 100.0 + border = [c.redF(), c.greenF(), c.blueF(), alpha] + tool = self._mode._tool + if tool == TOOL_ARROW: + inner = list(border) + elif self._mode._filled and tool in (TOOL_RECT, TOOL_CIRCLE): + inner = list(border) + else: + inner = [c.redF(), c.greenF(), c.blueF(), 0.0] + return border, inner + + # ------------------------------------------------------------------ + # Paint node resolution + # ------------------------------------------------------------------ + + def _find_paint_node(self): + try: + frame = commands.frame() + infos = commands.metaEvaluate(frame, commands.viewNode()) + + # If live_review has nominated a specific paint node (via + # set-current-annotate-mode-node), prefer it so that RV-drawn + # annotations land on the annotation source group's node — the same + # one live_review tracks — rather than the local pipeline node. + preferred = getattr(self._mode, "_preferred_paint_node", "") + if preferred: + for info in infos: + if info.get("node") == preferred: + return preferred, info["frame"] + # Preferred node isn't part of the current view (e.g. it was set + # while viewing a different view/layout) — fall through to normal + # resolution below rather than trusting a stale override. Matches + # legacy annotate_mode.mu's updateCurrentNode(), which never uses + # _userSelectedNode unless it's found in the current metaEvaluate. + + store_on_src = getattr(self._mode, "_store_on_src", False) + if store_on_src: + for info in infos: + if info.get("nodeType") == "RVPaint": + node = info["node"] + try: + grp = commands.nodeGroup(node) + if grp and commands.nodeType(grp) == "RVSourceGroup": + return node, info["frame"] + except Exception: + pass + + for info in infos: + if info.get("nodeType") == "RVPaint": + return info["node"], info["frame"] + all_paint = commands.nodesOfType("RVPaint") + if all_paint: + return all_paint[0], frame + return None, None + except Exception: + return None, None + + def _next_id(self, paint_node): + prop = f"{paint_node}.paint.nextId" + if not commands.propertyExists(prop): + commands.newProperty(prop, commands.IntType, 1) + commands.setIntProperty(prop, [0]) + i = commands.getIntProperty(prop)[0] + 1 + commands.setIntProperty(prop, [i]) + return i + + def _ensure_visible(self, paint_node): + prop = f"{paint_node}.paint.show" + if not commands.propertyExists(prop): + commands.newProperty(prop, commands.IntType, 1) + commands.setIntProperty(prop, [1], True) + + def _unique_name(self, paint_node, prefix, frame): + node_id = self._next_id(paint_node) + host = commands.myNetworkHost().replace(".", "_") + pid = os.getpid() + return f"{paint_node}.{prefix}:{node_id}:{frame}:{host}_{pid}" + + def _frame_order_and_undo(self, paint_node, frame, node_name, shape_uuid): + """Insert the component into the frame draw order and undo stack.""" + + def _ensure(prop, ptype, w): + if not commands.propertyExists(prop): + commands.newProperty(prop, ptype, w) + + component = node_name.split(".")[-1] + order_prop = f"{paint_node}.frame:{frame}.order" + _ensure(order_prop, commands.StringType, 1) + if component not in commands.getStringProperty(order_prop): + commands.insertStringProperty(order_prop, [component]) + + host = commands.myNetworkHost().replace(".", "_") + pid = os.getpid() + undo_prop = f"{paint_node}.frame:{frame}.userUndoStack:{host}_{pid}" + _ensure(undo_prop, commands.StringType, 1) + commands.insertStringProperty(undo_prop, [shape_uuid, "create"]) + + if getattr(self._mode, "_auto_mark", False): + try: + commands.markFrame(frame, True) + except Exception: + pass + + # ------------------------------------------------------------------ + # Pointer / coordinate helpers + # ------------------------------------------------------------------ + + def _pointer_location(self, event): + """Return (image_name, _Vec2) in image space, or ("", None). + + imagesAtPixel returns nodes outermost→innermost (sorted by imageNum descending): + [displayGroup_colorPipeline, defaultSequence_sequence, sourceGroup_source] + The display pipeline entry has a zoom-dependent transform that diverges from + PaintIPNode's source-image coordinate space after zoom/pan. + We iterate from innermost (last) to outermost and use the first entry for which + eventToImageSpace succeeds, because when viewing composite layouts (default stack, + default sequence) the innermost entry may be a virtual composite image whose + source name is not valid for eventToImageSpace. + """ + try: + raw = event.pointer() + dpr = commands.devicePixelRatio() + ip = (raw[0] * dpr, raw[1] * dpr) + + pinfos = commands.imagesAtPixel(raw) + if not pinfos: + return "", None + + for info in reversed(pinfos): + name = info.get("name", "") + if not name: + continue + try: + pei_raw = commands.eventToImageSpace(name, ip, True) + return name, _Vec2(pei_raw[0], pei_raw[1]) + except Exception: + continue + + return "", None + except Exception as e: + import traceback + + print(f"[annotate_beta] _pointer_location error: {e}") + traceback.print_exc() + return "", None + + # ------------------------------------------------------------------ + # Shape node creation / update + # ------------------------------------------------------------------ + + def _new_shape(self, paint_node, frame, prefix, anchor, cur): + try: + self._ensure_visible(paint_node) + n = self._unique_name(paint_node, prefix, frame) + bw = self._border_width() + border, inner = self._colors() + shape_uuid = str(uuid.uuid4()) + + def _ensure(prop, ptype, w): + if not commands.propertyExists(prop): + commands.newProperty(prop, ptype, w) + + _ensure(f"{n}.startFrame", commands.IntType, 1) + _ensure(f"{n}.duration", commands.IntType, 1) + _ensure(f"{n}.eye", commands.IntType, 1) + commands.setIntProperty(f"{n}.startFrame", [frame], True) + commands.setIntProperty(f"{n}.duration", [1], True) + commands.setIntProperty(f"{n}.eye", [2], True) + + if prefix in ("rect", "ellipse"): + for prop, w in ((".min", 2), (".max", 2), (".innerColor", 4), (".borderColor", 4), (".borderWidth", 1)): + _ensure(f"{n}{prop}", commands.FloatType, w) + min_x = min(anchor.x, cur.x) + min_y = min(anchor.y, cur.y) + max_x = max(anchor.x, cur.x) + max_y = max(anchor.y, cur.y) + commands.setFloatProperty(f"{n}.min", [min_x, min_y], True) + commands.setFloatProperty(f"{n}.max", [max_x, max_y], True) + commands.setFloatProperty(f"{n}.innerColor", inner, True) + commands.setFloatProperty(f"{n}.borderColor", border, True) + commands.setFloatProperty(f"{n}.borderWidth", [bw], True) + else: + for prop, w in ((".startPos", 2), (".endPos", 2), (".borderColor", 4), (".borderWidth", 1)): + _ensure(f"{n}{prop}", commands.FloatType, w) + commands.setFloatProperty(f"{n}.startPos", [anchor.x, anchor.y], True) + commands.setFloatProperty(f"{n}.endPos", [cur.x, cur.y], True) + commands.setFloatProperty(f"{n}.borderColor", border, True) + commands.setFloatProperty(f"{n}.borderWidth", [bw], True) + if prefix == "arrow": + _ensure(f"{n}.innerColor", commands.FloatType, 4) + _ensure(f"{n}.thickness", commands.FloatType, 1) + commands.setFloatProperty(f"{n}.innerColor", inner, True) + commands.setFloatProperty(f"{n}.thickness", [bw], True) + + _ensure(f"{n}.uuid", commands.StringType, 1) + _ensure(f"{n}.softDeleted", commands.IntType, 1) + commands.setStringProperty(f"{n}.uuid", [shape_uuid], True) + commands.setIntProperty(f"{n}.softDeleted", [0], True) + + self._frame_order_and_undo(paint_node, frame, n, shape_uuid) + + commands.redraw() + return n + except Exception as e: + import traceback + + print(f"[annotate_beta] _new_shape error: {e}") + traceback.print_exc() + return None + + def _update_shape(self, shape_node, prefix, anchor, cur): + if shape_node is None: + return + try: + if prefix in ("rect", "ellipse"): + commands.setFloatProperty(f"{shape_node}.min", [min(anchor.x, cur.x), min(anchor.y, cur.y)], True) + commands.setFloatProperty(f"{shape_node}.max", [max(anchor.x, cur.x), max(anchor.y, cur.y)], True) + else: + commands.setFloatProperty(f"{shape_node}.endPos", [cur.x, cur.y], True) + commands.redraw() + except Exception as e: + print(f"[annotate_beta] _update_shape error: {e}") + + # ------------------------------------------------------------------ + # Text node creation / update + # ------------------------------------------------------------------ + + def _new_text_node(self, paint_node, frame, pos): + """Create an empty text node at pos and return its property path.""" + self._begin_sync() + try: + self._ensure_visible(paint_node) + n = self._unique_name(paint_node, "text", frame) + shape_uuid = str(uuid.uuid4()) + + c = self._mode._colour + color = [c.redF(), c.greenF(), c.blueF(), 1.0] + + font_size = _FONT_SIZE_WCS_BASE.get(self._mode._font_size, 48.0 / 1080.0) * self._screen_scale() + font_weight = "bold" if self._mode._font_bold else "normal" + font_style = "italic" if self._mode._font_italic else "normal" + text_deco = "underline" if self._mode._font_underline else "none" + + def _ensure(prop, ptype, w): + if not commands.propertyExists(prop): + commands.newProperty(prop, ptype, w) + + for prop, ptype, w in ( + (".position", commands.FloatType, 2), + (".color", commands.FloatType, 4), + (".size", commands.FloatType, 1), + (".scale", commands.FloatType, 1), + (".rotation", commands.FloatType, 1), + (".spacing", commands.FloatType, 1), + (".font", commands.StringType, 1), + (".text", commands.StringType, 1), + (".origin", commands.StringType, 1), + (".debug", commands.IntType, 1), + (".startFrame", commands.IntType, 1), + (".duration", commands.IntType, 1), + (".mode", commands.IntType, 1), + ): + _ensure(f"{n}{prop}", ptype, w) + + commands.setFloatProperty(f"{n}.position", [pos.x, pos.y], True) + commands.setFloatProperty(f"{n}.color", color, True) + commands.setFloatProperty(f"{n}.size", [0.01], True) + commands.setFloatProperty(f"{n}.scale", [1.0], True) + commands.setFloatProperty(f"{n}.rotation", [0.0], True) + commands.setFloatProperty(f"{n}.spacing", [0.8], True) + commands.setStringProperty(f"{n}.font", [""], True) + commands.setStringProperty(f"{n}.text", ["|"], True) + commands.setStringProperty(f"{n}.origin", [""], True) + commands.setIntProperty(f"{n}.debug", [0], True) + commands.setIntProperty(f"{n}.startFrame", [frame], True) + commands.setIntProperty(f"{n}.duration", [1], True) + commands.setIntProperty(f"{n}.mode", [0], True) + + for prop, ptype, w in ( + (".fontFamily", commands.StringType, 1), + (".fontSize", commands.FloatType, 1), + (".fontWeight", commands.StringType, 1), + (".fontStyle", commands.StringType, 1), + (".textDecoration", commands.StringType, 1), + (".textAlign", commands.StringType, 1), + ): + _ensure(f"{n}{prop}", ptype, w) + + commands.setStringProperty(f"{n}.fontFamily", [self._mode._font_family], True) + commands.setFloatProperty(f"{n}.fontSize", [font_size], True) + commands.setStringProperty(f"{n}.fontWeight", [font_weight], True) + commands.setStringProperty(f"{n}.fontStyle", [font_style], True) + commands.setStringProperty(f"{n}.textDecoration", [text_deco], True) + commands.setStringProperty(f"{n}.textAlign", ["left"], True) + + _ensure(f"{n}.uuid", commands.StringType, 1) + _ensure(f"{n}.softDeleted", commands.IntType, 1) + commands.setStringProperty(f"{n}.uuid", [shape_uuid], True) + commands.setIntProperty(f"{n}.softDeleted", [0], True) + + self._frame_order_and_undo(paint_node, frame, n, shape_uuid) + commands.redraw() + self._end_sync() + return n + except Exception as e: + import traceback + + print(f"[annotate_beta] _new_text_node error: {e}") + traceback.print_exc() + self._end_sync() + return None + + def _ensure_text_node(self): + """Create the text node on first keypress if not yet created.""" + if self._text_node is not None: + return + if not self._text_paint_node or self._text_anchor is None: + return + node = self._new_text_node(self._text_paint_node, self._text_frame, self._text_anchor) + if node: + self._text_node = node + self._undo_stack.append((self._text_paint_node, self._text_frame, node)) + self._redo_stack.clear() + self._notify_buttons() + + def _update_text_display(self, cursor=True): + if self._text_node is None: + return + display = self._text_buffer + "|" if cursor else self._text_buffer + try: + commands.setStringProperty(f"{self._text_node}.text", [display], True) + commands.redraw() + except Exception as e: + print(f"[annotate_beta] _update_text_display error: {e}") + + def _commit_text(self): + # Nothing typed — clean up the placeholder node rather than leaving an empty annotation + if not self._text_buffer: + self._cancel_text() + return + self._update_text_display(cursor=False) + self._text_active = False + self._text_node = None + self._text_buffer = "" + self._text_paint_node = None + self._text_frame = None + commands.sendInternalEvent("annotate-text-committed") + + def _cancel_text(self): + if self._text_node: + try: + self._remove_from_order(self._text_paint_node, self._text_frame, self._text_node) + commands.setIntProperty(f"{self._text_node}.softDeleted", [1], True) + commands.redraw() + except Exception: + pass + self._text_active = False + self._text_node = None + self._text_anchor = None + self._text_buffer = "" + self._text_paint_node = None + self._text_frame = None + + # ------------------------------------------------------------------ + # Pen/eraser stroke node creation + # ------------------------------------------------------------------ + + def _pressure_width(self, event): + """Return pen width scaled by stylus pressure; falls back to 1.0 for mouse.""" + try: + p = max(0.01, min(1.0, event.pressure())) + except Exception: + p = 1.0 + return self._pen_stroke_width * p + + def _new_stroke(self, paint_node, frame, first_point, brush, erase_mode, first_point_width=None): + """Create a new pen/eraser stroke component and return its property path.""" + try: + self._ensure_visible(paint_node) + n = self._unique_name(paint_node, "pen", frame) + stroke_uuid = str(uuid.uuid4()) + + c = self._mode._colour + alpha = self._mode._opacity / 100.0 + blend_mode = getattr(self._mode, "_color_modifier", "normal") + + if blend_mode == "additive": + s = 1.0 + alpha + color = [c.redF() * s, c.greenF() * s, c.blueF() * s, alpha * alpha] + elif blend_mode == "darken": + s = (1.0 - alpha) * 0.75 + 0.25 + color = [c.redF() * s, c.greenF() * s, c.blueF() * s, alpha * alpha] + else: + color = [c.redF(), c.greenF(), c.blueF(), alpha] + + t = (self._mode._size - _PEN_SLIDER_MIN) / (_PEN_SLIDER_MAX - _PEN_SLIDER_MIN) + scale = self._screen_scale() if getattr(self._mode, "_scale_brush", True) else 1.0 + width = (_PEN_WIDTH_MIN + t * (_PEN_WIDTH_MAX - _PEN_WIDTH_MIN)) * scale + self._pen_stroke_width = width + push_width = first_point_width if first_point_width is not None else width + + def _ensure(prop, ptype, w): + if not commands.propertyExists(prop): + commands.newProperty(prop, ptype, w) + + for prop, ptype, w in ( + (".color", commands.FloatType, 4), + (".width", commands.FloatType, 1), + (".brush", commands.StringType, 1), + (".uuid", commands.StringType, 1), + (".points", commands.FloatType, 2), + (".join", commands.IntType, 1), + (".cap", commands.IntType, 1), + (".splat", commands.IntType, 1), + (".mode", commands.IntType, 1), + (".debug", commands.IntType, 1), + (".smoothingWidth", commands.FloatType, 1), + (".startFrame", commands.IntType, 1), + (".duration", commands.IntType, 1), + # Stamp-brush properties (only take effect for brush names other than + # "circle"/"gauss" — see PaintIPNode::compilePenComponent). Not yet + # exposed in this UI; written here so future sliders/pickers have a + # ready-made property to set. + (".hardness", commands.FloatType, 1), + (".tipTexture", commands.StringType, 1), + (".blendMode", commands.IntType, 1), + ): + _ensure(f"{n}{prop}", ptype, w) + + # mode: 0=OverMode, 1=EraseMode, 2=ScaleMode (burn/dodge use ScaleMode) + if erase_mode: + stroke_mode = 1 + elif blend_mode in ("additive", "darken"): + stroke_mode = 2 # ScaleMode + else: + stroke_mode = 0 # OverMode + + # All properties read by _get_paint_start must be written before .points, + # because setting .points fires graph-state-change which immediately tries + # to build the PAINT_START payload for the LiveReview package. + _ensure(f"{n}.softDeleted", commands.IntType, 1) + commands.setFloatProperty(f"{n}.color", color, True) + commands.setFloatProperty(f"{n}.width", [push_width], True) + commands.setStringProperty(f"{n}.brush", [brush], True) + commands.setIntProperty(f"{n}.mode", [stroke_mode], True) + commands.setIntProperty(f"{n}.startFrame", [frame], True) + commands.setIntProperty(f"{n}.duration", [1], True) + commands.setStringProperty(f"{n}.uuid", [stroke_uuid], True) + commands.setIntProperty(f"{n}.softDeleted", [0], True) + commands.setFloatProperty(f"{n}.points", [first_point.x, first_point.y], True) + commands.setIntProperty(f"{n}.join", [1], True) # RoundJoin + commands.setIntProperty(f"{n}.cap", [2], True) # RoundCap + commands.setIntProperty(f"{n}.splat", [1 if brush == "gauss" else 0], True) + commands.setIntProperty(f"{n}.debug", [0], True) + commands.setFloatProperty(f"{n}.smoothingWidth", [1.0], True) + commands.setFloatProperty(f"{n}.hardness", [100.0], True) + commands.setStringProperty(f"{n}.tipTexture", [""], True) + commands.setIntProperty(f"{n}.blendMode", [2 if blend_mode == "additive" else 0], True) + + self._frame_order_and_undo(paint_node, frame, n, stroke_uuid) + commands.redraw() + return n + except Exception as e: + import traceback as _tb + + print(f"[annotate_beta] _new_stroke error: {e}") + _tb.print_exc() + return None + + def _pen_push(self, event): + if commands.isPlaying(): + commands.stop() + self._begin_sync() + paint_node, frame = self._find_paint_node() + if paint_node is None: + self._end_sync() + event.reject() + return + name, pei = self._pointer_location(event) + if not name: + self._end_sync() + event.reject() + return + self._current_source_name = name + # Refresh cached stroke width so _pressure_width() uses the current + # zoom's _screen_scale(), not the stale value from the previous stroke. + t = (self._mode._size - _PEN_SLIDER_MIN) / (_PEN_SLIDER_MAX - _PEN_SLIDER_MIN) + scale = self._screen_scale() if getattr(self._mode, "_scale_brush", True) else 1.0 + self._pen_stroke_width = (_PEN_WIDTH_MIN + t * (_PEN_WIDTH_MAX - _PEN_WIDTH_MIN)) * scale + tool = self._mode._tool + if self._stylus_erasing: + erase = True + brush = getattr(self._mode, "_eraser_brush", "circle") + else: + erase = tool == TOOL_ERASER + if tool == TOOL_AIRBRUSH: + brush = "gauss" + elif tool == TOOL_ERASER: + brush = getattr(self._mode, "_eraser_brush", "circle") + else: + brush = "circle" + stroke = self._new_stroke(paint_node, frame, pei, brush, erase, self._pressure_width(event)) + if stroke: + self._pen_stroke = stroke + self._pen_paint_node = paint_node + self._pen_frame = frame + commands.sendInternalEvent("set-current-annotate-mode-node", paint_node) + # sync accumulation is still open — will be flushed at _pen_release + else: + self._end_sync() + + def _pen_drag(self, event): + if not self._pen_stroke: + return + name, pei = self._pointer_location(event) + if not name: + return + try: + commands.insertFloatProperty(f"{self._pen_stroke}.points", [pei.x, pei.y]) + commands.insertFloatProperty(f"{self._pen_stroke}.width", [self._pressure_width(event)]) + commands.redraw() + if not getattr(self._mode, "_sync_whole_strokes", True): + self._end_sync(force=True) + self._begin_sync() + except Exception as e: + print(f"[annotate_beta] _pen_drag error: {e}") + + def _pen_release(self, event): + if not self._pen_stroke: + return + name, pei = self._pointer_location(event) + if name and pei: + try: + commands.insertFloatProperty(f"{self._pen_stroke}.points", [pei.x, pei.y]) + commands.insertFloatProperty(f"{self._pen_stroke}.width", [self._pressure_width(event)]) + except Exception: + pass + # Commit to undo stack now that stroke is complete + self._undo_stack.append((self._pen_paint_node, self._pen_frame, self._pen_stroke)) + self._redo_stack.clear() + self._notify_buttons() + commands.sendInternalEvent("annotate-stroke-released") + self._pen_stroke = None + self._pen_paint_node = None + self._pen_frame = None + commands.redraw() + # End the whole-stroke accumulation started in _pen_push and flush to network. + self._end_sync(force=True) + + # ------------------------------------------------------------------ + # Shift constraint (shapes only) + # ------------------------------------------------------------------ + + def _constrain(self, prefix, anchor, cur): + dx = cur.x - anchor.x + dy = cur.y - anchor.y + if prefix in ("rect", "ellipse"): + if self._constraint_angle is not None: + cx = math.cos(self._constraint_angle) + cy = math.sin(self._constraint_angle) + proj = max(dx * cx + dy * cy, 0.0) + return _Vec2(anchor.x + proj * cx, anchor.y + proj * cy) + side = min(abs(dx), abs(dy)) + return _Vec2( + anchor.x + (side if dx >= 0 else -side), + anchor.y + (side if dy >= 0 else -side), + ) + else: + length = math.sqrt(dx * dx + dy * dy) + if length < 1e-5: + return cur + angle = math.atan2(dy, dx) + snapped = round(angle / (math.pi / 4)) * (math.pi / 4) + return _Vec2( + anchor.x + length * math.cos(snapped), + anchor.y + length * math.sin(snapped), + ) + + # ------------------------------------------------------------------ + # Common shape push / release + # ------------------------------------------------------------------ + + def _do_push(self, pei, paint_node, frame): + if commands.isPlaying(): + commands.stop() + commands.setFrame(frame) + prefix = _PREFIX[self._mode._tool] + self._anchor = pei + self._last_pei = pei + self._shape_type = prefix + self._current_node = paint_node + self._current_frame = frame + self._shape_active = True + self._shift_transition = False + commands.sendInternalEvent("set-current-annotate-mode-node", paint_node) + # Open a sync accumulation block that stays open until _do_release so the + # entire shape (push → drag → release) is sent as one batch to Live Review + # participants instead of immediately broadcasting the initial zero-size shape. + self._begin_sync() + self._current_shape = self._new_shape(paint_node, frame, prefix, pei, pei) + if self._current_shape: + self._undo_stack.append((self._current_node, self._current_frame, self._current_shape)) + self._redo_stack.clear() + self._notify_buttons() + else: + self._end_sync(force=True) + + def _do_release(self, pei): + if pei is not None: + self._update_shape(self._current_shape, self._shape_type, self._anchor, pei) + self._shape_active = False + self._current_shape = None + commands.sendInternalEvent("annotate-shape-released") + commands.redraw() + self._end_sync(force=True) + + # ------------------------------------------------------------------ + # Pointer event handlers + # ------------------------------------------------------------------ + + def on_push(self, event): + self._notify_draw_started() + if self._mode._tool == TOOL_TEXT: + # Commit any in-progress text, then record the new anchor. + # The node is NOT created yet — it is deferred to the first keypress + # so that clicking without typing leaves nothing behind. + if self._text_active: + self._commit_text() + name, pei = self._pointer_location(event) + if not name: + return + self._current_source_name = name + paint_node, frame = self._find_paint_node() + self._text_active = True + self._text_buffer = "" + self._text_node = None + self._text_anchor = pei + self._text_paint_node = paint_node + self._text_frame = frame + return + + if self._mode._tool in (TOOL_PEN, TOOL_AIRBRUSH, TOOL_ERASER): + self._pen_push(event) + return + + if not self._is_shape_tool(): + event.reject() + return + if self._shift_transition: + self._shift_transition = False + return + paint_node, frame = self._find_paint_node() + if paint_node is None: + event.reject() + return + name, pei = self._pointer_location(event) + if not name: + event.reject() + return + self._current_source_name = name + self._constraint_angle = None + self._do_push(pei, paint_node, frame) + + def on_drag(self, event): + if self._mode._tool == TOOL_TEXT: + return # consume without action during text placement + if self._mode._tool in (TOOL_PEN, TOOL_AIRBRUSH, TOOL_ERASER): + self._pen_drag(event) + return + if not self._is_shape_tool() or not self._shape_active: + event.reject() + return + name, pei = self._pointer_location(event) + if not name: + return + self._last_pei = pei + self._update_shape(self._current_shape, self._shape_type, self._anchor, pei) + + def on_release(self, event): + if self._mode._tool == TOOL_TEXT: + return + if self._mode._tool in (TOOL_PEN, TOOL_AIRBRUSH, TOOL_ERASER): + self._pen_release(event) + return + if not self._is_shape_tool() or not self._shape_active: + event.reject() + return + if self._shift_transition: + return + name, pei = self._pointer_location(event) + self._do_release(pei if name else None) + + def on_push_shift(self, event): + self._notify_draw_started() + if self._mode._tool == TOOL_TEXT: + return + if self._mode._tool in (TOOL_PEN, TOOL_AIRBRUSH, TOOL_ERASER): + self._pen_push(event) + return + if not self._is_shape_tool(): + event.reject() + return + if self._shift_transition: + self._shift_transition = False + return + paint_node, frame = self._find_paint_node() + if paint_node is None: + event.reject() + return + name, pei = self._pointer_location(event) + if not name: + event.reject() + return + self._current_source_name = name + self._constraint_angle = None + self._do_push(pei, paint_node, frame) + + def on_drag_shift(self, event): + if self._mode._tool == TOOL_TEXT: + return + if self._mode._tool in (TOOL_PEN, TOOL_AIRBRUSH, TOOL_ERASER): + self._pen_drag(event) + return + if not self._is_shape_tool() or not self._shape_active: + event.reject() + return + name, pei = self._pointer_location(event) + if not name: + return + self._last_pei = pei + self._update_shape( + self._current_shape, self._shape_type, self._anchor, self._constrain(self._shape_type, self._anchor, pei) + ) + + def on_release_shift(self, event): + if self._mode._tool == TOOL_TEXT: + return + if self._mode._tool in (TOOL_PEN, TOOL_AIRBRUSH, TOOL_ERASER): + self._pen_release(event) + return + if not self._is_shape_tool() or not self._shape_active: + event.reject() + return + if self._shift_transition: + return + name, pei = self._pointer_location(event) + if name: + self._do_release(self._constrain(self._shape_type, self._anchor, pei)) + else: + self._do_release(None) + + def on_shift_down(self, event): + if self._text_active: + return # don't interfere with text shift+letter input + if self._shape_active and self._anchor and self._last_pei: + dx = self._last_pei.x - self._anchor.x + dy = self._last_pei.y - self._anchor.y + self._constraint_angle = math.atan2(dy, dx) + self._shift_transition = True + + def on_shift_up(self, event): + if self._text_active: + return + self._constraint_angle = None + if self._shape_active: + self._shift_transition = True + + # ------------------------------------------------------------------ + # Text key handlers + # ------------------------------------------------------------------ + + def on_text_key(self, event): + if not self._text_active: + event.reject() + return + parts = event.name().split("--") + ch = parts[-1] + if len(ch) != 1: + event.reject() + return + has_shift = "shift" in parts[:-1] + if has_shift: + char = ch.upper() if ch.isalpha() else _SHIFT_MAP.get(ch, ch) + else: + char = ch + self._text_buffer += char + self._ensure_text_node() + self._update_text_display(cursor=True) + + def on_text_space(self, event): + if not self._text_active: + event.reject() + return + self._text_buffer += " " + self._ensure_text_node() + self._update_text_display(cursor=True) + + def on_text_backspace(self, event): + if not self._text_active: + event.reject() + return + if self._text_buffer: + self._text_buffer = self._text_buffer[:-1] + self._update_text_display(cursor=True) + + def on_text_commit(self, event): + if not self._text_active: + event.reject() + return + self._commit_text() + + def on_text_cancel(self, event): + if not self._text_active: + event.reject() + return + self._cancel_text() + + def on_key_up(self, event): + # Consume key-up events during text input so they don't propagate + if not self._text_active: + event.reject() + + def on_frame_changed(self, event): + if self._text_active: + self._commit_text() + event.reject() # let normal frame-change handling continue + + # ------------------------------------------------------------------ + # Undo / redo / clear + # ------------------------------------------------------------------ + + def has_undo(self): + return bool(self._undo_stack) + + def has_redo(self): + return bool(self._redo_stack) + + # ------------------------------------------------------------------ + # Order-list helpers + # ------------------------------------------------------------------ + + @staticmethod + def _component_name(node_name): + """Extract the component key from a full property path. + + e.g. "RVPaint_1.rect:3:42:host_1234" → "rect:3:42:host_1234" + """ + return node_name.split(".", 1)[1] if "." in node_name else node_name + + def _remove_from_order(self, paint_node, frame, node_name): + """Remove a component from the frame draw-order list.""" + if not paint_node: + return + order_prop = f"{paint_node}.frame:{frame}.order" + if not commands.propertyExists(order_prop): + return + comp = self._component_name(node_name) + current = list(commands.getStringProperty(order_prop)) + if comp in current: + current.remove(comp) + commands.setStringProperty(order_prop, current, True) + + def _restore_to_order(self, paint_node, frame, node_name): + """Re-append a component to the frame draw-order list.""" + if not paint_node: + return + order_prop = f"{paint_node}.frame:{frame}.order" + if not commands.propertyExists(order_prop): + return + comp = self._component_name(node_name) + current = list(commands.getStringProperty(order_prop)) + if comp not in current: + commands.insertStringProperty(order_prop, [comp]) + + # ------------------------------------------------------------------ + # Undo / redo / clear + # ------------------------------------------------------------------ + + def undo(self): + if not self._undo_stack: + return + paint_node, frame, node_name = self._undo_stack.pop() + self._begin_sync() + try: + self._remove_from_order(paint_node, frame, node_name) + commands.setIntProperty(f"{node_name}.softDeleted", [1], True) + commands.redraw() + except Exception as e: + print(f"[annotate_beta] undo error: {e}") + self._end_sync(force=True) + self._redo_stack.append((paint_node, frame, node_name)) + self._notify_buttons() + commands.sendInternalEvent("undo-paint", self._uuid_for(node_name)) + + def redo(self): + if not self._redo_stack: + return + paint_node, frame, node_name = self._redo_stack.pop() + self._begin_sync() + try: + self._restore_to_order(paint_node, frame, node_name) + commands.setIntProperty(f"{node_name}.softDeleted", [0], True) + commands.redraw() + except Exception as e: + print(f"[annotate_beta] redo error: {e}") + self._end_sync(force=True) + self._undo_stack.append((paint_node, frame, node_name)) + self._notify_buttons() + commands.sendInternalEvent("redo-paint", self._uuid_for(node_name)) + + @staticmethod + def _annotated_frames(paint_node): + """Return the set of frame numbers that have an order property on paint_node. + + Scans actual properties rather than iterating a frame range so that + annotations stored at source-space frame numbers outside the current + timeline range are still found. + """ + try: + frames = set() + for prop in commands.properties(paint_node): + # prop is e.g. "RVPaint_1.frame:42.order" + parts = prop.split(".") + if len(parts) >= 3 and parts[2] == "order": + comp_parts = parts[1].split(":") + if len(comp_parts) == 2 and comp_parts[0] == "frame": + frames.add(int(comp_parts[1])) + return frames + except Exception: + return set() + + def clear_frame(self): + """Remove all visible nodes on the current frame from the draw order. + + Iterates every RVPaint node so that annotations received from remote + clients (which land on the live-review annotation source group's node) + are cleared along with locally drawn ones. + """ + _, frame = self._find_paint_node() + if frame is None: + return + all_paint_nodes = commands.nodesOfType("RVPaint") + if not all_paint_nodes: + return + self._begin_sync() + cleared = [] + cleared_uuids = [] + for paint_node in all_paint_nodes: + order_prop = f"{paint_node}.frame:{frame}.order" + if not commands.propertyExists(order_prop): + continue + components = list(commands.getStringProperty(order_prop)) + surviving = [] + node_cleared = False + for comp in components: + node_name = f"{paint_node}.{comp}" + deleted_prop = f"{node_name}.softDeleted" + try: + already_deleted = commands.propertyExists(deleted_prop) and commands.getIntProperty(deleted_prop)[0] + if not already_deleted: + commands.setIntProperty(deleted_prop, [1], True) + cleared.append((paint_node, frame, node_name)) + uuid = self._uuid_for(node_name) + if uuid: + cleared_uuids.append(uuid) + node_cleared = True + else: + surviving.append(comp) + except Exception: + surviving.append(comp) + if node_cleared: + commands.setStringProperty(order_prop, surviving, True) + self._end_sync(force=True) + if cleared: + self._undo_stack.extend(cleared) + self._redo_stack.clear() + commands.redraw() + self._notify_buttons() + primary = all_paint_nodes[0] + payload = "|".join(cleared_uuids) if cleared_uuids else f"{primary}:{frame}" + commands.sendInternalEvent("clear-paint", payload) + + def clear_all_frames(self): + """Soft-delete all visible nodes on every frame across all paint nodes. + + Iterates every RVPaint node and discovers annotated frames from actual + properties (matching the old annotate_mode.mu behaviour) so that remote + annotations and source-space frame numbers outside the timeline range + are also cleared. + """ + all_paint_nodes = commands.nodesOfType("RVPaint") + if not all_paint_nodes: + return + self._begin_sync() + any_cleared = False + cleared_uuids = [] + for paint_node in all_paint_nodes: + for frame in self._annotated_frames(paint_node): + order_prop = f"{paint_node}.frame:{frame}.order" + if not commands.propertyExists(order_prop): + continue + components = list(commands.getStringProperty(order_prop)) + surviving = [] + frame_cleared = False + for comp in components: + node_name = f"{paint_node}.{comp}" + deleted_prop = f"{node_name}.softDeleted" + try: + already_deleted = ( + commands.propertyExists(deleted_prop) and commands.getIntProperty(deleted_prop)[0] + ) + if not already_deleted: + commands.setIntProperty(deleted_prop, [1], True) + uuid = self._uuid_for(node_name) + if uuid: + cleared_uuids.append(uuid) + frame_cleared = True + else: + surviving.append(comp) + except Exception: + surviving.append(comp) + if frame_cleared: + commands.setStringProperty(order_prop, surviving, True) + any_cleared = True + self._end_sync(force=True) + self._undo_stack.clear() + self._redo_stack.clear() + self._notify_buttons() + if any_cleared: + commands.redraw() + payload = "|".join(cleared_uuids) if cleared_uuids else all_paint_nodes[0] + commands.sendInternalEvent("clear-all-paint", payload) + + def _uuid_for(self, node_name): + """Return the UUID stored on a paint node, or empty string if unavailable.""" + try: + uuid_prop = f"{node_name}.uuid" + if commands.propertyExists(uuid_prop): + return commands.getStringProperty(uuid_prop)[0] + except Exception: + pass + return "" + + def _notify_buttons(self): + """Tell the mode to update undo/redo button enabled state.""" + try: + self._mode._update_undo_redo_buttons() + except Exception: + pass + + def _notify_draw_started(self): + """Tell the mode to close any open popups (e.g. colour picker).""" + try: + self._mode._hide_colour_picker() + except Exception: + pass + + # ------------------------------------------------------------------ + # Stylus eraser-end handlers + # ------------------------------------------------------------------ + + def on_stylus_eraser_push(self, event): + """Physical eraser end of stylus: always draws with erase mode regardless of tool.""" + self._notify_draw_started() + self._stylus_erasing = True + self._pen_push(event) + + def on_stylus_eraser_drag(self, event): + self._pen_drag(event) + + def on_stylus_eraser_release(self, event): + self._pen_release(event) + self._stylus_erasing = False + + # ------------------------------------------------------------------ + # Sync helpers + # ------------------------------------------------------------------ + + @staticmethod + def _begin_sync(): + """Signal sync.mu to start batching graph-state-change events (RV-to-RV sync).""" + commands.sendInternalEvent("internal-sync-begin-accumulate") + + @staticmethod + def _end_sync(force=False): + """Signal sync.mu to flush the current batch; force=True sends immediately.""" + commands.sendInternalEvent("internal-sync-end-accumulate") + if force: + commands.sendInternalEvent("internal-sync-flush") + + +class _Vec2: + __slots__ = ("x", "y") + + def __init__(self, x, y): + self.x = x + self.y = y diff --git a/src/plugins/rv-packages/annotate_beta/annotate_beta_mode.py b/src/plugins/rv-packages/annotate_beta/annotate_beta_mode.py new file mode 100644 index 000000000..432b8ee4d --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/annotate_beta_mode.py @@ -0,0 +1,729 @@ +# Copyright (c) 2025 Autodesk, Inc. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from rv import commands, extra_commands, rvtypes, qtutils + +try: + from PySide2 import QtCore, QtWidgets, QtGui +except ImportError: + from PySide6 import QtCore, QtWidgets, QtGui + +from annotate_beta_widget import ( + AnnotateToolbarDockWidget, + TOOL_PEN, + TOOL_TEXT, + TOOL_AIRBRUSH, + TOOL_ERASER, + TOOL_EYEDROPPER, + COLOR_MOD_ADDITIVE, + COLOR_MOD_DARKEN, +) +from annotate_beta_engine import AnnotateDrawEngine, TABLE_NAME, _DRAWING_TOOLS + +_DEFAULT_COLOUR_HEX = "#ffdc00" +_DEFAULT_SIZE = 32 +_DEFAULT_OPACITY = 50 + +_SETTINGS_GROUP = "AnnotateBeta" +_ALL_TOOLS = ("cursor", "pen", "airbrush", "eraser", "rect", "circle", "arrow", "line", "text", "eyedropper") + + +class AnnotateBetaMode(rvtypes.MinorMode): + def __init__(self): + rvtypes.MinorMode.__init__(self) + + self._dock = None + self._shape_table_pushed = False + + # Current tool state — drawing engine reads these + self._tool = TOOL_PEN + self._colour = QtGui.QColor(_DEFAULT_COLOUR_HEX) + self._size = _DEFAULT_SIZE + self._opacity = _DEFAULT_OPACITY + self._filled = False + self._font_family = "Helvetica" + self._font_size = "medium" + self._font_bold = False + self._font_italic = False + self._font_underline = False + self._color_modifier = "normal" + self._eraser_brush = "circle" # "circle" = hard, "gauss" = soft + + # Paint node override set by live_review via set-current-annotate-mode-node. + # When non-empty, the engine uses this node instead of metaEvaluate so that + # RV-drawn annotations land on the annotation source group's RVPaint node + # (the same one live_review tracks) rather than the local pipeline node. + self._preferred_paint_node = "" + + # True when the dock was hidden because Live Review revoked annotate_category + # permission. Used to restore the toolbar when permission is re-granted, + # without auto-showing it on unrelated event-category-state-changed events + # (e.g., menu-bar clicks). + self._hidden_by_lr = False + + # Configure settings — match old annotate_mode.mu defaults + self._store_on_src = False # "Draw On Source When Possible" + self._auto_mark = False # "Automatically Mark Annotated Frames" + self._link_tool_colors = False # "Unique Color For Each Tool" = NOT linked + self._sync_whole_strokes = True # True = batch stroke; False = send each point live + self._sync_auto_start = False # "Start Automatically During Sync" + self._scale_brush = True # "Brush Size Relative to View" + self._auto_save_settings = True # "Always Save Settings as Defaults On Exit" + + # Per-tool state memory — keyed by tool identifier. + self._tool_colours = {} + self._tool_sizes = {} + self._tool_opacities = {} + self._tool_color_modifiers = {} + + self._engine = AnnotateDrawEngine(self) + + self.init( + "annotate_beta_mode", + self.global_bindings, + [], # no global pointer bindings — only the named table below + self.menu, + ) + + # Register the named event table AFTER init() so mode name is set. + self._engine.setup_event_table(self) + + self._create_dock() + + # ------------------------------------------------------------------ + # MinorMode interface + # ------------------------------------------------------------------ + + def activate(self): + rvtypes.MinorMode.activate(self) + commands.sendInternalEvent("annotate-mode-activated", "") + # Re-push the event table in case the mode was deactivated by a view change. + if self._tool in _DRAWING_TOOLS: + self._push_shape_table() + + self._show_toolbar() + self._update_tool_availability() + self._update_undo_redo_buttons() + + def deactivate(self): + self._pop_shape_table() + self._engine.commit_text_if_active() + self._dock.toolbar_widget.hide_popups() + self._dock.hide() + if self._auto_save_settings: + self._save_configure_settings() + rvtypes.MinorMode.deactivate(self) + + # ------------------------------------------------------------------ + # Event table push / pop + # ------------------------------------------------------------------ + + def _push_shape_table(self): + if not self._shape_table_pushed: + commands.pushEventTable(TABLE_NAME) + self._shape_table_pushed = True + + def _pop_shape_table(self): + if self._shape_table_pushed: + commands.popEventTable() + self._shape_table_pushed = False + + # ------------------------------------------------------------------ + # Settings persistence + # ------------------------------------------------------------------ + + @staticmethod + def _read(group, key, default): + """readSettings wrapper that handles None returns and missing-key exceptions.""" + try: + val = commands.readSettings(group, key, default) + return val if val is not None else default + except Exception: + return default + + def _load_settings(self): + """Read per-tool state from RV settings and apply to the in-memory dicts and widget.""" + g = _SETTINGS_GROUP + for tool in _ALL_TOOLS: + hex_col = self._read(g, f"{tool}_colour", _DEFAULT_COLOUR_HEX) + colour = QtGui.QColor(hex_col) + self._tool_colours[tool] = colour if colour.isValid() else QtGui.QColor(_DEFAULT_COLOUR_HEX) + self._tool_sizes[tool] = int(self._read(g, f"{tool}_size", _DEFAULT_SIZE)) + self._tool_opacities[tool] = int(self._read(g, f"{tool}_opacity", _DEFAULT_OPACITY)) + self._tool_color_modifiers[tool] = self._read(g, f"{tool}_color_modifier", "normal") + + saved_tool = self._read(g, "active_tool", TOOL_PEN) + if saved_tool in _ALL_TOOLS: + self._tool = saved_tool + + self._eraser_brush = self._read(g, "eraser_brush", "circle") + + self._font_family = self._read(g, "font_family", "Helvetica") + self._font_size = self._read(g, "font_size", "medium") + self._font_bold = bool(self._read(g, "font_bold", False)) + self._font_italic = bool(self._read(g, "font_italic", False)) + self._font_underline = bool(self._read(g, "font_underline", False)) + + # Configure settings + self._store_on_src = bool(self._read(g, "cfg_store_on_src", False)) + self._auto_mark = bool(self._read(g, "cfg_auto_mark", False)) + self._link_tool_colors = bool(self._read(g, "cfg_link_tool_colors", False)) + self._sync_whole_strokes = bool(self._read(g, "cfg_sync_whole_strokes", True)) + self._scale_brush = bool(self._read(g, "cfg_scale_brush", True)) + self._auto_save_settings = bool(self._read(g, "cfg_auto_save_settings", True)) + self._sync_auto_start = self._read_sync_auto_start() + + # Apply the saved state for the active tool to the live variables and widget. + self._colour = QtGui.QColor(self._tool_colours.get(self._tool, QtGui.QColor(_DEFAULT_COLOUR_HEX))) + self._size = self._tool_sizes.get(self._tool, _DEFAULT_SIZE) + self._opacity = self._tool_opacities.get(self._tool, _DEFAULT_OPACITY) + self._color_modifier = self._tool_color_modifiers.get(self._tool, "normal") + + w = self._dock.toolbar_widget + w.strip.set_active_tool(self._tool) + w.panel.set_page_for_tool(self._tool) + w.set_colour(self._colour) + w.set_size(self._size) + w.set_opacity(self._opacity) + w.panel.set_color_modifier(self._color_modifier) + w.panel.set_eraser_brush(self._eraser_brush) + w.panel.set_font_family(self._font_family) + w.panel.set_font_size(self._font_size) + w.panel.set_bold(self._font_bold) + w.panel.set_italic(self._font_italic) + w.panel.set_underline(self._font_underline) + + def _save_tool_state(self, tool): + """Persist colour/size/opacity for one tool.""" + g = _SETTINGS_GROUP + colour = self._tool_colours.get(tool, QtGui.QColor(_DEFAULT_COLOUR_HEX)) + try: + commands.writeSettings(g, f"{tool}_colour", colour.name()) + commands.writeSettings(g, f"{tool}_size", self._tool_sizes.get(tool, _DEFAULT_SIZE)) + commands.writeSettings(g, f"{tool}_opacity", self._tool_opacities.get(tool, _DEFAULT_OPACITY)) + commands.writeSettings(g, f"{tool}_color_modifier", self._tool_color_modifiers.get(tool, "normal")) + except Exception as e: + print(f"[annotate_beta] settings write error: {e}") + + def _save_configure_settings(self): + """Persist Configure submenu settings.""" + g = _SETTINGS_GROUP + try: + commands.writeSettings(g, "cfg_store_on_src", self._store_on_src) + commands.writeSettings(g, "cfg_auto_mark", self._auto_mark) + commands.writeSettings(g, "cfg_link_tool_colors", self._link_tool_colors) + commands.writeSettings(g, "cfg_sync_whole_strokes", self._sync_whole_strokes) + commands.writeSettings(g, "cfg_scale_brush", self._scale_brush) + commands.writeSettings(g, "cfg_auto_save_settings", self._auto_save_settings) + except Exception as e: + print(f"[annotate_beta] configure settings write error: {e}") + + def _read_sync_auto_start(self): + """Return True if this mode is in the Sync extraModes list.""" + try: + modes = self._read("Sync", "extraModes", []) + if isinstance(modes, str): + modes = [modes] if modes else [] + return "annotate_beta_mode" in modes + except Exception: + return False + + def _toggle_sync_auto_start(self): + """Add or remove this mode from the Sync extraModes list.""" + try: + modes = self._read("Sync", "extraModes", []) + if isinstance(modes, str): + modes = [modes] if modes else [] + else: + modes = list(modes) + name = "annotate_beta_mode" + if name in modes: + modes.remove(name) + self._sync_auto_start = False + else: + modes.append(name) + self._sync_auto_start = True + commands.writeSettings("Sync", "extraModes", modes) + except Exception as e: + print(f"[annotate_beta] sync auto-start toggle error: {e}") + + # ------------------------------------------------------------------ + # Dock creation + # ------------------------------------------------------------------ + + def _create_dock(self): + sw = qtutils.sessionWindow() + self._dock = AnnotateToolbarDockWidget(sw) + # Hide the native title bar while docked (we have our own Draw header). + # Restore it when floating so the user can drag the window. + self._dock.setTitleBarWidget(QtWidgets.QWidget()) + self._dock.topLevelChanged.connect(self._on_dock_top_level_changed) + sw.addDockWidget(QtCore.Qt.RightDockWidgetArea, self._dock) + self._dock.hide() # Start hidden; user opens via Annotation menu + sw.resizeDocks([self._dock], [115], QtCore.Qt.Horizontal) + + for existing in sw.findChildren(QtWidgets.QDockWidget): + if existing is self._dock: + continue + if sw.dockWidgetArea(existing) == QtCore.Qt.RightDockWidgetArea: + sw.tabifyDockWidget(existing, self._dock) + break + + w = self._dock.toolbar_widget + w.tool_changed.connect(self._on_tool_changed) + w.colour_changed.connect(self._on_colour_changed) + w.size_changed.connect(self._on_size_changed) + w.opacity_changed.connect(self._on_opacity_changed) + w.filled_changed.connect(self._on_filled_changed) + w.font_family_changed.connect(self._on_font_family_changed) + w.font_size_changed.connect(self._on_font_size_changed) + w.font_bold_changed.connect(self._on_font_bold_changed) + w.font_italic_changed.connect(self._on_font_italic_changed) + w.font_underline_changed.connect(self._on_font_underline_changed) + w.undo_requested.connect(self._on_undo) + w.redo_requested.connect(self._on_redo) + w.clear_requested.connect(self._on_clear) + w.clear_all_requested.connect(self._on_clear_all) + w.close_requested.connect(self._on_close_requested) + w.dock_requested.connect(self._on_dock_requested) + w.color_modifier_changed.connect(self._on_color_modifier_changed) + w.eraser_brush_changed.connect(self._on_eraser_brush_changed) + + # Load persisted settings after all signals are wired so the widget + # updates (set_colour/set_size/set_opacity) don't emit change signals + # back into the mode before it's fully ready. + self._load_settings() + + # ------------------------------------------------------------------ + # Signal handlers + # ------------------------------------------------------------------ + + def _on_tool_changed(self, tool): + # Always commit text when leaving the text tool, regardless of where we go. + if self._tool == TOOL_TEXT: + self._engine.commit_text_if_active() + + # Save current state for the outgoing tool. + self._tool_colours[self._tool] = QtGui.QColor(self._colour) + self._tool_sizes[self._tool] = self._size + self._tool_opacities[self._tool] = self._opacity + self._tool_color_modifiers[self._tool] = self._color_modifier + self._save_tool_state(self._tool) + + outgoing_tool = self._tool + self._tool = tool + commands.writeSettings(_SETTINGS_GROUP, "active_tool", tool) + + # If we just came from the eyedropper, propagate the sampled colour to + # the incoming tool instead of restoring its previous colour. + if outgoing_tool == TOOL_EYEDROPPER: + self._tool_colours[tool] = QtGui.QColor(self._colour) + self._save_tool_state(tool) + else: + # Restore colour for the incoming tool. + restored_colour = self._tool_colours.get(tool, QtGui.QColor(self._colour)) + if restored_colour != self._colour: + self._colour = QtGui.QColor(restored_colour) + self._dock.toolbar_widget.set_colour(restored_colour) + + # Restore size/opacity for the incoming tool. + restored_size = self._tool_sizes.get(tool, self._size) + restored_opacity = self._tool_opacities.get(tool, self._opacity) + if restored_size != self._size or restored_opacity != self._opacity: + self._size = restored_size + self._opacity = restored_opacity + self._dock.toolbar_widget.set_size(restored_size) + self._dock.toolbar_widget.set_opacity(restored_opacity) + + restored_blend = self._tool_color_modifiers.get(tool, "normal") + if restored_blend != self._color_modifier: + self._color_modifier = restored_blend + self._dock.toolbar_widget.panel.set_color_modifier(restored_blend) + + if tool in _DRAWING_TOOLS: + self._push_shape_table() + else: + self._pop_shape_table() + + # Eyedropper: crosshair cursor over the viewport while active. + sw = qtutils.sessionWindow() + if tool == TOOL_EYEDROPPER: + sw.setCursor(QtCore.Qt.CrossCursor) + else: + sw.unsetCursor() + + def _on_colour_changed(self, colour): + self._colour = colour + if self._link_tool_colors: + for tool in _ALL_TOOLS: + self._tool_colours[tool] = QtGui.QColor(colour) + self._save_tool_state(tool) + else: + self._tool_colours[self._tool] = QtGui.QColor(colour) + self._save_tool_state(self._tool) + + def _on_size_changed(self, v): + self._size = v + self._tool_sizes[self._tool] = v + self._save_tool_state(self._tool) + + def _on_opacity_changed(self, v): + self._opacity = v + self._tool_opacities[self._tool] = v + self._save_tool_state(self._tool) + + def _on_eraser_brush_changed(self, brush): + self._eraser_brush = brush + try: + commands.writeSettings(_SETTINGS_GROUP, "eraser_brush", brush) + except Exception as e: + print(f"[annotate_beta] settings write error: {e}") + + def _on_filled_changed(self, v): + self._filled = v + + def _on_font_family_changed(self, v): + self._engine.commit_text_if_active() + self._font_family = v + commands.writeSettings(_SETTINGS_GROUP, "font_family", v) + + def _on_font_size_changed(self, v): + self._engine.commit_text_if_active() + self._font_size = v + commands.writeSettings(_SETTINGS_GROUP, "font_size", v) + + def _on_font_bold_changed(self, v): + self._engine.commit_text_if_active() + self._font_bold = v + commands.writeSettings(_SETTINGS_GROUP, "font_bold", v) + + def _on_font_italic_changed(self, v): + self._engine.commit_text_if_active() + self._font_italic = v + commands.writeSettings(_SETTINGS_GROUP, "font_italic", v) + + def _on_font_underline_changed(self, v): + self._engine.commit_text_if_active() + self._font_underline = v + commands.writeSettings(_SETTINGS_GROUP, "font_underline", v) + + def _on_color_modifier_changed(self, mode): + self._color_modifier = mode + self._tool_color_modifiers[self._tool] = mode + self._save_tool_state(self._tool) + + def _on_undo(self): + self._engine.undo() + self._update_undo_redo_buttons() + + def _on_redo(self): + self._engine.redo() + self._update_undo_redo_buttons() + + def _on_undo_event(self, event): + if not self._dock or not self._dock.isVisible(): + event.reject() + return + self._engine.undo() + self._update_undo_redo_buttons() + + def _on_redo_event(self, event): + if not self._dock or not self._dock.isVisible(): + event.reject() + return + self._engine.redo() + self._update_undo_redo_buttons() + + def _on_clear(self): + self._engine.clear_frame() + self._update_undo_redo_buttons() + + def _on_clear_all(self): + self._engine.clear_all_frames() + self._update_undo_redo_buttons() + + def _on_dock_top_level_changed(self, floating): + """Restore the native title bar when floating (enables dragging); hide it when docked.""" + if floating: + self._dock.setTitleBarWidget(None) + else: + self._dock.setTitleBarWidget(QtWidgets.QWidget()) + + def _show_toolbar(self): + self._dock.show() + self._dock.raise_() + + def _on_close_requested(self): + """Closing the panel turns the whole mode off. + + Matches the legacy annotate_mode package, where DrawDockWidget.closeEvent() + calls mode.toggle() so the event tables, menu state and bottom-bar button all + follow the panel. deactivate() is what actually hides the dock. + """ + if self.isActive(): + self.toggle() + else: + self._dock.hide() + + def _on_dock_requested(self): + self._dock.setFloating(not self._dock.isFloating()) + + def _hide_colour_picker(self): + if self._dock: + self._dock.toolbar_widget.hide_popups() + + def _update_undo_redo_buttons(self): + if self._dock: + w = self._dock.toolbar_widget + w.set_undo_enabled(self._engine.has_undo()) + w.set_redo_enabled(self._engine.has_redo()) + + def _update_tool_availability(self): + """Enable/disable tools whose RV event categories are currently disabled. + + Live Review uses commands.disableEventCategory() to prevent webclient + users from accessing tools not supported over the web protocol. + Also hides the dock entirely when annotate_category is disabled (viewer role). + """ + if not self._dock: + return + w = self._dock.toolbar_widget + + # Hide/show the toolbar based on Live Review role permissions. + # Only restore visibility when permission was previously revoked by Live Review + # (_hidden_by_lr=True); do NOT auto-show on unrelated category-state events + # (e.g., menu-bar clicks) which would pop the toolbar open unexpectedly. + if commands.isEventCategoryEnabled("annotate_category"): + if self._hidden_by_lr and self.isActive(): + self._show_toolbar() + self._hidden_by_lr = False + else: + if self._dock.isVisible(): + self._hidden_by_lr = True + self._dock.hide() + + airbrush_on = commands.isEventCategoryEnabled("annotate_airbrush_category") + burn_on = commands.isEventCategoryEnabled("annotate_burn_category") + dodge_on = commands.isEventCategoryEnabled("annotate_dodge_category") + soft_erase_on = commands.isEventCategoryEnabled("annotate_softerase_category") + hard_erase_on = commands.isEventCategoryEnabled("annotate_harderase_category") + text_on = commands.isEventCategoryEnabled("annotate_text_category") + sample_on = commands.isEventCategoryEnabled("annotate_sample_category") + + w.set_tool_enabled(TOOL_AIRBRUSH, airbrush_on) + w.set_blend_mode_enabled(COLOR_MOD_DARKEN, burn_on) + w.set_blend_mode_enabled(COLOR_MOD_ADDITIVE, dodge_on) + w.set_soft_erase_enabled(soft_erase_on) + # Disable the eraser tool entirely only when both erase modes are unavailable. + w.set_tool_enabled(TOOL_ERASER, hard_erase_on or soft_erase_on) + w.set_tool_enabled(TOOL_TEXT, text_on) + w.set_tool_enabled(TOOL_EYEDROPPER, sample_on) + + def _on_category_state_changed(self, event): + self._update_tool_availability() + event.reject() + + def _on_undo_redo_clear_update(self, event): + """Fired by live_review's paint manager after processing an incoming PAINT_BATCH_UPDATE. + + A remote client's undo/redo/clear changes the annotation state on the current + frame, so our undo/redo button enabled state may be stale. + """ + self._update_undo_redo_buttons() + event.reject() + + def _on_set_current_annotate_node(self, event): + """Receive the paint node that live_review wants annotations stored on. + + Live Review fires this event (via on_annotate_mode_activated) to direct + us to draw on the annotation source group's RVPaint node rather than the + local pipeline node, so that it can track and sync all annotations. + + Matches legacy annotate_mode.mu's setCurrentNodeEvent: only accept the + node if it's actually part of the current view's paint nodes, otherwise + clear the override rather than storing a name that may never resolve. + """ + node_name = event.contents() or "" + self._preferred_paint_node = "" + if node_name: + try: + infos = commands.metaEvaluate(commands.frame(), commands.viewNode()) + if any(i.get("nodeType") == "RVPaint" and i.get("node") == node_name for i in infos): + self._preferred_paint_node = node_name + except Exception: + pass + event.reject() + + # ------------------------------------------------------------------ + # Configure menu handlers + # ------------------------------------------------------------------ + + def _cfg_toggle_store_on_src(self, e): + self._store_on_src = not self._store_on_src + + def _cfg_state_store_on_src(self): + return commands.CheckedMenuState if self._store_on_src else commands.NeutralMenuState + + def _cfg_toggle_auto_mark(self, e): + self._auto_mark = not self._auto_mark + + def _cfg_state_auto_mark(self): + return commands.CheckedMenuState if self._auto_mark else commands.NeutralMenuState + + def _cfg_toggle_link_colors(self, e): + self._link_tool_colors = not self._link_tool_colors + if self._link_tool_colors: + for tool in _ALL_TOOLS: + self._tool_colours[tool] = QtGui.QColor(self._colour) + self._save_tool_state(tool) + + def _cfg_state_link_colors(self): + return commands.CheckedMenuState if self._link_tool_colors else commands.NeutralMenuState + + def _cfg_toggle_live_drawing(self, e): + self._sync_whole_strokes = not self._sync_whole_strokes + + def _cfg_state_live_drawing(self): + return commands.CheckedMenuState if not self._sync_whole_strokes else commands.NeutralMenuState + + def _cfg_toggle_sync_auto_start(self, e): + self._toggle_sync_auto_start() + + def _cfg_state_sync_auto_start(self): + return commands.CheckedMenuState if self._sync_auto_start else commands.NeutralMenuState + + def _cfg_toggle_scale_brush(self, e): + self._scale_brush = not self._scale_brush + + def _cfg_state_scale_brush(self): + return commands.CheckedMenuState if self._scale_brush else commands.NeutralMenuState + + def _cfg_toggle_auto_save(self, e): + self._auto_save_settings = not self._auto_save_settings + + def _cfg_state_auto_save(self): + return commands.CheckedMenuState if self._auto_save_settings else commands.NeutralMenuState + + # ------------------------------------------------------------------ + # Bindings and menu + # ------------------------------------------------------------------ + + @property + def global_bindings(self): + return [ + ("pointer-1--push", self._on_eyedropper_click, "Eyedropper sample"), + ("stylus-pen--push", self._on_eyedropper_click, "Eyedropper sample (stylus)"), + ("key-down--control--z", self._on_undo_event, "Undo"), + ("key-down--control--y", self._on_redo_event, "Redo"), + ("key-down--meta--z", self._on_undo_event, "Undo (mac)"), + ("key-down--meta--shift--z", self._on_redo_event, "Redo (mac)"), + ("event-category-state-changed", self._on_category_state_changed, "Update tool availability"), + ("set-current-annotate-mode-node", self._on_set_current_annotate_node, "Set preferred paint node"), + ( + "undo-redo-clear-ui-update", + self._on_undo_redo_clear_update, + "Sync undo/redo state after remote paint update", + ), + # Matches the legacy annotate_mode package's Next/Previous Annotated Frame hotkeys. + # (RV joins simultaneous modifiers with a single dash, e.g. "alt-shift", and + # brackets the modifier block with double dashes -- see QTTranslator::modifierString.) + ("key-down--alt-shift--right", self._next_annotated_frame, "Next Annotated Frame"), + ("key-down--alt-shift--left", self._prev_annotated_frame, "Previous Annotated Frame"), + ] + + def _on_eyedropper_click(self, event): + if self._tool != TOOL_EYEDROPPER: + event.reject() + return + try: + raw = event.pointer() + dpr = commands.devicePixelRatio() + x = raw[0] * dpr + y = raw[1] * dpr + colour = commands.framebufferPixelValue(x, y) + if colour and len(colour) >= 3: + qcol = QtGui.QColor.fromRgbF( + min(1.0, max(0.0, colour[0])), + min(1.0, max(0.0, colour[1])), + min(1.0, max(0.0, colour[2])), + ) + self._colour = qcol + self._tool_colours[self._tool] = QtGui.QColor(qcol) + self._dock.toolbar_widget.set_colour(qcol) + self._save_tool_state(self._tool) + except Exception as e: + print(f"[annotate_beta] eyedropper error: {e}") + + # ------------------------------------------------------------------ + # Next/Previous Annotated Frame navigation + # ------------------------------------------------------------------ + # Ported from the legacy annotate_mode.mu nextAnnotatedFrame/prevAnnotatedFrame: + # findAnnotatedFrames() returns an unsorted, possibly-duplicated frame list, so + # scan the whole array for the closest next/previous frame rather than sorting. + + def _next_annotated_frame(self, event=None): + frames = extra_commands.findAnnotatedFrames() + if not frames: + return + current = commands.frame() + new_frame = frames[0] + for f in frames: + if new_frame <= current or (f > current and f < new_frame): + new_frame = f + commands.setFrame(new_frame) + + def _prev_annotated_frame(self, event=None): + frames = extra_commands.findAnnotatedFrames() + if not frames: + return + current = commands.frame() + new_frame = frames[0] + for f in frames: + if f < current and (new_frame >= current or f > new_frame): + new_frame = f + commands.setFrame(new_frame) + + def _next_prev_state(self): + return commands.DisabledMenuState if extra_commands.isSessionEmpty() else commands.UncheckedMenuState + + @property + def menu(self): + configure_items = [ + ("Draw On Source When Possible", self._cfg_toggle_store_on_src, None, self._cfg_state_store_on_src), + ("Automatically Mark Annotated Frames", self._cfg_toggle_auto_mark, None, self._cfg_state_auto_mark), + ("Link Tool Colors", self._cfg_toggle_link_colors, None, self._cfg_state_link_colors), + ("Live Drawing in Sync", self._cfg_toggle_live_drawing, None, self._cfg_state_live_drawing), + ( + "Start Automatically During Sync", + self._cfg_toggle_sync_auto_start, + None, + self._cfg_state_sync_auto_start, + ), + ("Brush Size Relative to View", self._cfg_toggle_scale_brush, None, self._cfg_state_scale_brush), + ("Always Save Settings as Defaults On Exit", self._cfg_toggle_auto_save, None, self._cfg_state_auto_save), + ] + + return [ + ( + "Annotation", + [ + ( + "Next Annotated Frame", + self._next_annotated_frame, + "alt shift rightArrow", + self._next_prev_state, + ), + ( + "Previous Annotated Frame", + self._prev_annotated_frame, + "alt shift leftArrow", + self._next_prev_state, + ), + ("Configure", configure_items), + ], + ), + ] + + +def createMode(): + return AnnotateBetaMode() diff --git a/src/plugins/rv-packages/annotate_beta/annotate_beta_widget.py b/src/plugins/rv-packages/annotate_beta/annotate_beta_widget.py new file mode 100644 index 000000000..fb9dc36a3 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/annotate_beta_widget.py @@ -0,0 +1,1531 @@ +# Copyright (c) 2025 Autodesk, Inc. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os + +try: + from PySide2 import QtCore, QtWidgets, QtGui +except ImportError: + from PySide6 import QtCore, QtWidgets, QtGui + +from annotate_beta_colour_picker import ColourPickerSection + + +def _load_bundled_fonts(): + """Register any .ttf/.otf files from the fonts/ directory next to this file.""" + fonts_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts") + if not os.path.isdir(fonts_dir): + return + db = QtGui.QFontDatabase() + for root, _dirs, files in os.walk(fonts_dir): + for fname in files: + if fname.lower().endswith((".ttf", ".otf")): + db.addApplicationFont(os.path.join(root, fname)) + + +# Tool identifiers +TOOL_CURSOR = "cursor" +TOOL_PEN = "pen" +TOOL_AIRBRUSH = "airbrush" +TOOL_ERASER = "eraser" +TOOL_RECT = "rect" +TOOL_CIRCLE = "circle" +TOOL_ARROW = "arrow" +TOOL_LINE = "line" +TOOL_TEXT = "text" +TOOL_EYEDROPPER = "eyedropper" + +# Secondary panel page indices +_PAGE_EMPTY = 0 # cursor, eyedropper +_PAGE_BRUSH = 1 # arrow, line +_PAGE_SHAPE = 2 # rect, circle +_PAGE_TEXT = 3 # text +_PAGE_PEN = 4 # pen and airbrush (size/opacity/blend mode) +_PAGE_ERASER = 5 # eraser (brush type combo + size/opacity) + +_TOOL_PAGE = { + TOOL_CURSOR: _PAGE_EMPTY, + TOOL_EYEDROPPER: _PAGE_EMPTY, + TOOL_PEN: _PAGE_PEN, + TOOL_AIRBRUSH: _PAGE_PEN, + TOOL_ERASER: _PAGE_ERASER, + TOOL_ARROW: _PAGE_BRUSH, + TOOL_LINE: _PAGE_BRUSH, + TOOL_RECT: _PAGE_SHAPE, + TOOL_CIRCLE: _PAGE_SHAPE, + TOOL_TEXT: _PAGE_TEXT, +} + +# Blend mode values passed to the mode/engine +COLOR_MOD_NORMAL = "normal" +COLOR_MOD_ADDITIVE = "additive" +COLOR_MOD_DARKEN = "darken" + +_TOOL_LABEL = { + TOOL_CURSOR: "↖", + TOOL_PEN: "✏", + TOOL_AIRBRUSH: "∴", + TOOL_ERASER: "⌫", + TOOL_RECT: "▭", + TOOL_CIRCLE: "○", + TOOL_ARROW: "↗", + TOOL_LINE: "╱", + TOOL_TEXT: "T", + TOOL_EYEDROPPER: "⌖", +} + +_TOOL_TOOLTIP = { + TOOL_CURSOR: "Cursor", + TOOL_PEN: "Pen", + TOOL_AIRBRUSH: "Airbrush", + TOOL_ERASER: "Eraser", + TOOL_RECT: "Rectangle", + TOOL_CIRCLE: "Circle", + TOOL_ARROW: "Arrow", + TOOL_LINE: "Line", + TOOL_TEXT: "Text", + TOOL_EYEDROPPER: "Eyedropper", +} + +# --------------------------------------------------------------------------- +# Stylesheets +# --------------------------------------------------------------------------- + +_HEADER_SS = """ + QWidget#AnnotateToolbarHeader { + background: #171717; + border-bottom: 1px solid rgba(255, 255, 255, 51); + } + QToolButton#HeaderBtn { + background: transparent; + border: none; + color: rgba(255, 255, 255, 153); + font-size: 11px; + border-radius: 2px; + padding: 0px; + min-width: 16px; + min-height: 16px; + max-width: 16px; + max-height: 16px; + } + QToolButton#HeaderBtn:hover { color: #ffffff; background: rgba(255, 255, 255, 26); } + QLabel#HeaderTitle { + color: #ffffff; + font-size: 12px; + font-weight: bold; + background: transparent; + } +""" + +_MENU_SS = """ + QMenu { + background: #2D2D2D; + border: 1px solid rgba(255, 255, 255, 64); + border-radius: 4px; + color: #F5F5F5; + font-size: 12px; + padding: 4px 0px; + } + QMenu::item { + padding: 4px 16px; + background: transparent; + } + QMenu::item:selected { background: rgba(255, 255, 255, 51); } +""" + +_STRIP_SS = """ + QToolTip { + color: #F5F5F5; + background: #2D2D2D; + border: 1px solid rgba(255, 255, 255, 64); + border-radius: 2px; + padding: 2px 4px; + } + QWidget#AnnotateToolStrip { + background: #1f1f1f; + } + QToolButton { + background: rgba(255, 255, 255, 26); + border: none; + color: #c8c8c8; + font-size: 14px; + border-radius: 2px; + padding: 0px; + } + QToolButton:hover { background: rgba(255, 255, 255, 51); } + QToolButton:checked { background: rgba(56, 171, 223, 115); } + QToolButton:focus { border: 1px solid rgba(56, 171, 223, 89); } + QToolButton:disabled { opacity: 0.4; } + QToolButton[grouppos="first"] { + border-top-left-radius: 2px; border-top-right-radius: 2px; + border-bottom-left-radius: 0px; border-bottom-right-radius: 0px; + } + QToolButton[grouppos="mid"] { + border-radius: 0px; + } + QToolButton[grouppos="last"] { + border-top-left-radius: 0px; border-top-right-radius: 0px; + border-bottom-left-radius: 2px; border-bottom-right-radius: 2px; + } + QToolButton#ActionBtn { background: transparent; } + QToolButton#ActionBtn:hover { background: rgba(255, 255, 255, 51); } + QToolButton#ActionBtn:pressed { background: rgba(255, 255, 255, 64); } +""" + +_PANEL_SS = """ + QWidget#AnnotateSecondaryPanel, QWidget#AnnotateSecondaryPanel * { + background: #171717; + } + QLabel { + color: #707070; + font-size: 10px; + background: transparent; + } + QCheckBox { + color: #F5F5F5; + font-size: 12px; + spacing: 5px; + } + QCheckBox::indicator { + width: 12px; + height: 12px; + border: 1px solid rgba(255, 255, 255, 128); + border-radius: 0px; + background: #535353; + } + QCheckBox::indicator:hover { + border-color: rgba(255, 255, 255, 200); + } + QCheckBox::indicator:focus { + border-color: #38ABDF; + } + QCheckBox::indicator:checked { + border-color: #FFFFFF; + background: #FFFFFF; + } + QCheckBox::indicator:checked:hover { + border-color: rgba(255, 255, 255, 230); + } + QCheckBox::indicator:checked:focus { + border-color: #38ABDF; + } + QWidget#AnnotateSecondaryPanel QToolButton { + background: rgba(255, 255, 255, 26); + border: none; + color: #c8c8c8; + font-size: 11px; + border-radius: 2px; + padding: 1px 4px; + min-width: 18px; + min-height: 18px; + } + QWidget#AnnotateSecondaryPanel QToolButton:hover { background: rgba(255, 255, 255, 51); } + QWidget#AnnotateSecondaryPanel QToolButton:checked { background: rgba(56, 171, 223, 115); } + QWidget#AnnotateSecondaryPanel QToolButton:focus { border: 1px solid rgba(56, 171, 223, 89); } + QWidget#AnnotateSecondaryPanel QToolButton:disabled { opacity: 0.4; } + QWidget#AnnotateSecondaryPanel QToolButton[grouppos="first"] { + border-top-left-radius: 2px; border-top-right-radius: 2px; + border-bottom-left-radius: 0px; border-bottom-right-radius: 0px; + } + QWidget#AnnotateSecondaryPanel QToolButton[grouppos="mid"] { border-radius: 0px; } + QWidget#AnnotateSecondaryPanel QToolButton[grouppos="last"] { + border-top-left-radius: 0px; border-top-right-radius: 0px; + border-bottom-left-radius: 2px; border-bottom-right-radius: 2px; + } + QWidget#AnnotateSecondaryPanel QComboBox#EraserBrushCombo { + background: #171717; + } + QWidget#AnnotateSecondaryPanel QComboBox { + background: rgba(255, 255, 255, 26); + border: none; + border-bottom: 1px solid transparent; + border-radius: 2px; + color: #F5F5F5; + font-size: 12px; + font-weight: bold; + padding: 0px 4px; + min-height: 32px; + max-height: 32px; + } + QWidget#AnnotateSecondaryPanel QComboBox:hover { + border-bottom: 1px solid rgba(255, 255, 255, 200); + } + QWidget#AnnotateSecondaryPanel QComboBox:focus, + QWidget#AnnotateSecondaryPanel QComboBox:on { + border-bottom: 1px solid #38ABDF; + } + QWidget#AnnotateSecondaryPanel QComboBox::drop-down { + border: none; + background: transparent; + width: 16px; + subcontrol-origin: padding; + subcontrol-position: right center; + } + QWidget#AnnotateSecondaryPanel QComboBox::down-arrow { + width: 8px; + height: 5px; + } + QComboBox QAbstractItemView { + background: #171717; + border: 1px solid rgba(255, 255, 255, 51); + border-radius: 4px; + color: #F5F5F5; + font-size: 12px; + font-weight: normal; + selection-background-color: rgba(255, 255, 255, 51); + selection-color: #F5F5F5; + padding: 8px 0px; + outline: 0px; + } + QLineEdit#SliderValue { + background: rgba(255, 255, 255, 26); + border: none; + border-bottom: 1px solid transparent; + border-radius: 2px; + color: #F5F5F5; + font-size: 12px; + padding: 0px 4px; + min-height: 22px; + max-height: 22px; + selection-background-color: rgba(56, 171, 223, 51); + selection-color: #F5F5F5; + } + QLineEdit#SliderValue:hover { + border-bottom: 1px solid rgba(255, 255, 255, 200); + } + QLineEdit#SliderValue:focus { + border-bottom: 1px solid #38ABDF; + } + QLineEdit#SliderValue:disabled { + opacity: 0.4; + } +""" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _tool_button(label, tooltip, checkable=True, size=30): + btn = QtWidgets.QToolButton() + btn.setText(label) + btn.setToolTip(tooltip) + btn.setCheckable(checkable) + btn.setFixedSize(size, size) + return btn + + +def _separator(width=None): + sep = QtWidgets.QWidget() + sep.setFixedHeight(1) + if width is not None: + sep.setFixedWidth(width) + sep.setStyleSheet("QWidget { background: rgba(255, 255, 255, 64); }") + return sep + + +# --------------------------------------------------------------------------- +# Header bar +# --------------------------------------------------------------------------- + + +class _ToolbarHeader(QtWidgets.QWidget): + """Compact header bar: [×] [□] (spacer) Draw + + Signals: + close_requested -- user clicked × + dock_requested -- user clicked □ + """ + + close_requested = QtCore.Signal() + dock_requested = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("AnnotateToolbarHeader") + self.setFixedHeight(24) + self.setStyleSheet(_HEADER_SS) + + lay = QtWidgets.QHBoxLayout(self) + lay.setContentsMargins(4, 4, 8, 4) + lay.setSpacing(4) + + close_btn = QtWidgets.QToolButton() + close_btn.setObjectName("HeaderBtn") + close_btn.setText("×") + close_btn.setToolTip("Close") + close_btn.setFixedSize(16, 16) + close_btn.clicked.connect(self.close_requested) + lay.addWidget(close_btn) + + dock_btn = QtWidgets.QToolButton() + dock_btn.setObjectName("HeaderBtn") + dock_btn.setText("□") + dock_btn.setToolTip("Dock / Undock") + dock_btn.setFixedSize(16, 16) + dock_btn.clicked.connect(self.dock_requested) + lay.addWidget(dock_btn) + + lay.addStretch() + + title = QtWidgets.QLabel("Draw") + title.setObjectName("HeaderTitle") + lay.addWidget(title) + + +# --------------------------------------------------------------------------- +# Colour swatch +# --------------------------------------------------------------------------- + + +class ColourSwatch(QtWidgets.QAbstractButton): + """Square button showing the current annotation colour. + + Clicking it emits swatch_clicked — the parent is responsible for + showing/hiding the inline colour picker. + """ + + swatch_clicked = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self._colour = QtGui.QColor(255, 220, 0) + self.setFixedSize(30, 30) + self.setCursor(QtCore.Qt.PointingHandCursor) + self.setToolTip("Colour") + + def set_colour(self, colour): + self._colour = QtGui.QColor(colour) + self.update() + + def paintEvent(self, event): + p = QtGui.QPainter(self) + p.setRenderHint(QtGui.QPainter.Antialiasing, False) + p.setPen(QtGui.QPen(QtGui.QColor("#DCDCDC"), 1)) + p.setBrush(self._colour) + p.drawRect(self.rect().adjusted(1, 1, -1, -1)) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.swatch_clicked.emit() + + +# --------------------------------------------------------------------------- +# Custom slider widget (Figma Weave 3.0 design) +# --------------------------------------------------------------------------- + + +class _CustomSlider(QtWidgets.QWidget): + """Vertical slider matching the Figma Weave 3.0 spec. + + Rail: 1px wide, rgba(255,255,255,0.5) — above handle + Fill: 3px wide, #38ABDF — below handle + Handle: 18px dark circle (#171717) with 10px blue dot (#38ABDF) inside + Focus: 1px dark gap + 3px blue ring around inner dot + Disabled: opacity 0.5 + """ + + valueChanged = QtCore.Signal(int) + + _RAIL_W = 1 + _FILL_W = 3 + _OUTER_R = 9 # 18px diameter outer handle circle + _INNER_R = 5 # 10px diameter inner dot + _PAD = 12 # padding so handle doesn't clip (must be >= _OUTER_R=9) + + _COL_RAIL = QtGui.QColor(255, 255, 255, 128) + _COL_FILL = QtGui.QColor("#38ABDF") + _COL_OUTER = QtGui.QColor("#171717") + _COL_GAP = QtGui.QColor("#262626") + + def __init__(self, min_val=0, max_val=100, default=50, parent=None): + super().__init__(parent) + self._min = min_val + self._max = max_val + self._value = max(min_val, min(max_val, default)) + self._pressed = False + self.setFixedWidth(32) # Figma: 32px wide after -90deg rotation + self.setMinimumHeight(110) + self.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Expanding) + self.setCursor(QtCore.Qt.PointingHandCursor) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + def _handle_y(self): + top, bot = self._PAD, self.height() - self._PAD + t = (self._value - self._min) / max(1, self._max - self._min) + return int(bot - t * (bot - top)) + + def _y_to_value(self, y): + top, bot = self._PAD, self.height() - self._PAD + span = bot - top + if span <= 0: + return self._min + t = max(0.0, min(1.0, 1.0 - (y - top) / span)) + return int(round(self._min + t * (self._max - self._min))) + + def paintEvent(self, event): + p = QtGui.QPainter(self) + p.setRenderHint(QtGui.QPainter.Antialiasing) + p.setPen(QtCore.Qt.NoPen) + + if not self.isEnabled(): + p.setOpacity(0.5) + + cx = self.width() / 2.0 + top = float(self._PAD) + bot = float(self.height() - self._PAD) + hy = float(self._handle_y()) + + # Rail above handle (semi-transparent white, 1px) + rh = self._RAIL_W / 2.0 + p.setBrush(self._COL_RAIL) + p.drawRoundedRect(QtCore.QRectF(cx - rh, top, self._RAIL_W, hy - top), rh, rh) + + # Fill below handle (blue, 3px) + fh = self._FILL_W / 2.0 + p.setBrush(self._COL_FILL) + p.drawRoundedRect(QtCore.QRectF(cx - fh, hy, self._FILL_W, bot - hy), fh, fh) + + # Focus ring when pressed or keyboard-focused + if self._pressed or self.hasFocus(): + p.setBrush(self._COL_FILL) + p.drawEllipse(QtCore.QPointF(cx, hy), self._INNER_R + 1 + 3, self._INNER_R + 1 + 3) + p.setBrush(self._COL_GAP) + p.drawEllipse(QtCore.QPointF(cx, hy), self._INNER_R + 1, self._INNER_R + 1) + + # Handle outer dark circle + p.setBrush(self._COL_OUTER) + p.drawEllipse(QtCore.QPointF(cx, hy), self._OUTER_R, self._OUTER_R) + + # Handle inner blue dot + p.setBrush(self._COL_FILL) + p.drawEllipse(QtCore.QPointF(cx, hy), self._INNER_R, self._INNER_R) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._pressed = True + self.setFocus() + self._set_from_y(event.y()) + + def mouseMoveEvent(self, event): + if self._pressed: + self._set_from_y(event.y()) + + def mouseReleaseEvent(self, event): + self._pressed = False + self.update() + + def focusInEvent(self, event): + self.update() + + def focusOutEvent(self, event): + self.update() + + def keyPressEvent(self, event): + key = event.key() + step = max(1, (self._max - self._min) // 10) + if key == QtCore.Qt.Key_Up: + self._set_value(self._value + 1) + elif key == QtCore.Qt.Key_Down: + self._set_value(self._value - 1) + elif key == QtCore.Qt.Key_PageUp: + self._set_value(self._value + step) + elif key == QtCore.Qt.Key_PageDown: + self._set_value(self._value - step) + else: + super().keyPressEvent(event) + + def wheelEvent(self, event): + delta = event.angleDelta().y() + self._set_value(self._value + (1 if delta > 0 else -1)) + + def _set_from_y(self, y): + self._set_value(self._y_to_value(y)) + + def _set_value(self, v): + v = max(self._min, min(self._max, v)) + if v != self._value: + self._value = v + self.valueChanged.emit(v) + self.update() + + def value(self): + return self._value + + def setValue(self, v): + self._set_value(v) + + def setRange(self, mn, mx): + self._min = mn + self._max = mx + self._set_value(max(mn, min(mx, self._value))) + + +# --------------------------------------------------------------------------- +# Secondary panel option pages +# --------------------------------------------------------------------------- + + +class _ValueLineEdit(QtWidgets.QLineEdit): + """QLineEdit that selects all text when it receives focus.""" + + def focusInEvent(self, event): + super().focusInEvent(event) + QtCore.QTimer.singleShot(0, self.selectAll) + + +class _SliderSection(QtWidgets.QWidget): + """Vertical slider + editable value input.""" + + value_changed = QtCore.Signal(int) + + def __init__(self, label, min_val, max_val, default, suffix="", parent=None): + super().__init__(parent) + self._suffix = suffix + self._min = min_val + self._max = max_val + + lay = QtWidgets.QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(4) + + self._slider = _CustomSlider(min_val, max_val, default) + self._slider.setToolTip(label) + self._slider.valueChanged.connect(self._on_slider_changed) + slider_row = QtWidgets.QHBoxLayout() + slider_row.setContentsMargins(0, 0, 0, 0) + slider_row.addStretch() + slider_row.addWidget(self._slider) + slider_row.addStretch() + lay.addLayout(slider_row, 1) + + self._input = _ValueLineEdit(f"{default}{suffix}") + self._input.setObjectName("SliderValue") + self._input.setToolTip(label) + self._input.setAlignment(QtCore.Qt.AlignCenter) + self._input.setFixedWidth(48) + self._input.editingFinished.connect(self._on_input_committed) + lay.addWidget(self._input, alignment=QtCore.Qt.AlignHCenter) + + # Fixed after layout is set up — prevents sections in less-constrained panels + # (no blend buttons) from claiming leftover space. Preferred has GrowFlag set + # and quietly absorbs extra space; Fixed (no flags) takes exactly sizeHint. + self.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) + + def _on_slider_changed(self, v): + if not self._input.hasFocus(): + self._input.setText(f"{v}{self._suffix}") + self.value_changed.emit(v) + + def _on_input_committed(self): + text = self._input.text().strip() + if self._suffix and text.endswith(self._suffix): + text = text[: -len(self._suffix)].strip() + try: + v = max(self._min, min(self._max, int(round(float(text))))) + self._slider.setValue(v) + self._input.setText(f"{v}{self._suffix}") + self.value_changed.emit(v) + except (ValueError, TypeError): + self._input.setText(f"{self._slider.value()}{self._suffix}") + + def set_value(self, v): + self._slider.setValue(v) + if not self._input.hasFocus(): + self._input.setText(f"{v}{self._suffix}") + + +class _SizeOpacityPanel(QtWidgets.QWidget): + size_changed = QtCore.Signal(int) + opacity_changed = QtCore.Signal(int) + + def __init__(self, parent=None): + super().__init__(parent) + lay = QtWidgets.QVBoxLayout(self) + lay.setContentsMargins(8, 8, 8, 8) + lay.setSpacing(0) + + self._size = _SliderSection("Size", 1, 100, 32) + self._size.value_changed.connect(self.size_changed) + lay.addWidget(self._size) + + lay.addSpacing(8) + lay.addWidget(_separator(30), alignment=QtCore.Qt.AlignHCenter) + lay.addSpacing(8) + + self._opacity = _SliderSection("Opacity", 0, 100, 50, suffix="%") + self._opacity.value_changed.connect(self.opacity_changed) + lay.addWidget(self._opacity) + + lay.addStretch() + + def set_size(self, v): + self._size.set_value(v) + + def set_opacity(self, v): + self._opacity.set_value(v) + + +def _load_icon(name): + """Load a named SVG icon from the package's support files. + + Python files land in PlugIns/Python/ while package support files + land in PlugIns/SupportFiles/annotate_beta/. + """ + python_dir = os.path.dirname(os.path.abspath(__file__)) + support_dir = os.path.join(os.path.dirname(python_dir), "SupportFiles", "annotate_beta") + path = os.path.join(support_dir, f"icon_{name}.svg") + if os.path.exists(path): + return QtGui.QIcon(path) + return QtGui.QIcon() + + +def _apply_icon(btn, name, size=16): + """Set an SVG icon on a button; falls back to keeping existing text.""" + icon = _load_icon(name) + if not icon.isNull(): + btn.setIcon(icon) + btn.setIconSize(QtCore.QSize(size, size)) + btn.setText("") + + +def _apply_combo_style(combo): + """Apply per-widget combo QSS with runtime-resolved caret arrow images.""" + python_dir = os.path.dirname(os.path.abspath(__file__)) + support_dir = os.path.join(os.path.dirname(python_dir), "SupportFiles", "annotate_beta") + caret = os.path.join(support_dir, "icon_caret_down.svg").replace("\\", "/") + if os.path.exists(caret): + combo.setStyleSheet(f""" + QComboBox::drop-down {{ border: none; background: transparent; width: 16px; + subcontrol-origin: padding; subcontrol-position: right center; }} + QComboBox::down-arrow {{ image: url("{caret}"); width: 8px; height: 5px; }} + """) + + +def _apply_check_icon(checkbox): + """Apply Figma-spec checkbox stylesheet with runtime checkmark image path.""" + python_dir = os.path.dirname(os.path.abspath(__file__)) + support_dir = os.path.join(os.path.dirname(python_dir), "SupportFiles", "annotate_beta") + path = os.path.join(support_dir, "icon_check.svg").replace("\\", "/") + img = f'image: url("{path}");' if os.path.exists(path) else "" + checkbox.setStyleSheet(f""" + QCheckBox {{ + color: #F5F5F5; + font-size: 12px; + spacing: 5px; + }} + QCheckBox::indicator {{ + width: 12px; height: 12px; + border: 1px solid rgba(255, 255, 255, 128); + border-radius: 0px; + background: #535353; + }} + QCheckBox::indicator:hover {{ border-color: rgba(255, 255, 255, 200); }} + QCheckBox::indicator:focus {{ border-color: #38ABDF; }} + QCheckBox::indicator:checked {{ + border-color: #FFFFFF; background: #FFFFFF; {img} + }} + QCheckBox::indicator:checked:hover {{ + border-color: rgba(255, 255, 255, 230); background: #FFFFFF; {img} + }} + QCheckBox::indicator:checked:focus {{ + border-color: #38ABDF; background: #FFFFFF; {img} + }} + """) + + +class _EraserPanel(QtWidgets.QWidget): + """Brush-type combo + size/opacity sliders for the eraser tool.""" + + eraser_brush_changed = QtCore.Signal(str) # "circle" or "gauss" + size_changed = QtCore.Signal(int) + opacity_changed = QtCore.Signal(int) + + _BRUSHES = [ + ("circle", "erase_circle", "Circle (Hard)"), + ("gauss", "erase_gauss", "Gauss (Soft)"), + ] + + def __init__(self, parent=None): + super().__init__(parent) + lay = QtWidgets.QVBoxLayout(self) + lay.setContentsMargins(8, 8, 8, 8) + lay.setSpacing(0) + + self._brush_combo = QtWidgets.QComboBox() + self._brush_combo.setObjectName("EraserBrushCombo") + self._brush_combo.setToolTip("Brush Type") + self._brush_combo.setIconSize(QtCore.QSize(20, 20)) + for brush_id, icon_name, _label in self._BRUSHES: + icon = _load_icon(icon_name) + self._brush_combo.addItem(icon, "", brush_id) + self._brush_combo.currentIndexChanged.connect(self._on_brush_changed) + _apply_combo_style(self._brush_combo) + lay.addWidget(self._brush_combo) + + lay.addSpacing(8) + lay.addWidget(_separator(30), alignment=QtCore.Qt.AlignHCenter) + lay.addSpacing(8) + + self._size = _SliderSection("Size", 1, 100, 32) + self._size.value_changed.connect(self.size_changed) + lay.addWidget(self._size) + + lay.addSpacing(8) + lay.addWidget(_separator(30), alignment=QtCore.Qt.AlignHCenter) + lay.addSpacing(8) + + self._opacity = _SliderSection("Opacity", 0, 100, 50, suffix="%") + self._opacity.value_changed.connect(self.opacity_changed) + lay.addWidget(self._opacity) + + lay.addStretch() + + def _on_brush_changed(self, index): + brush = self._brush_combo.itemData(index) + if brush: + self.eraser_brush_changed.emit(brush) + + def set_eraser_brush(self, brush): + for i in range(self._brush_combo.count()): + if self._brush_combo.itemData(i) == brush: + self._brush_combo.setCurrentIndex(i) + return + + def set_soft_erase_enabled(self, enabled): + """Enable or disable the Gauss (soft) eraser brush option.""" + model = self._brush_combo.model() + for i in range(self._brush_combo.count()): + if self._brush_combo.itemData(i) == "gauss": + item = model.item(i) + if item is None: + break + if enabled: + item.setFlags(item.flags() | QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable) + else: + item.setFlags(item.flags() & ~(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable)) + if self._brush_combo.currentIndex() == i: + self._brush_combo.setCurrentIndex(0) + break + + def set_size(self, v): + self._size.set_value(v) + + def set_opacity(self, v): + self._opacity.set_value(v) + + +class _PenPanel(QtWidgets.QWidget): + """Size + opacity sliders plus Normal / Darken / Additive blend mode buttons.""" + + size_changed = QtCore.Signal(int) + opacity_changed = QtCore.Signal(int) + color_modifier_changed = QtCore.Signal(str) # COLOR_MOD_NORMAL / COLOR_MOD_ADDITIVE / COLOR_MOD_DARKEN + + def __init__(self, parent=None): + super().__init__(parent) + lay = QtWidgets.QVBoxLayout(self) + lay.setContentsMargins(8, 8, 8, 8) + lay.setSpacing(0) + + self._size = _SliderSection("Size", 1, 100, 32) + self._size.value_changed.connect(self.size_changed) + lay.addWidget(self._size) + + lay.addSpacing(8) + lay.addWidget(_separator(30), alignment=QtCore.Qt.AlignHCenter) + lay.addSpacing(8) + + self._opacity = _SliderSection("Opacity", 0, 100, 50, suffix="%") + self._opacity.value_changed.connect(self.opacity_changed) + lay.addWidget(self._opacity) + + lay.addSpacing(8) + lay.addWidget(_separator(30), alignment=QtCore.Qt.AlignHCenter) + lay.addSpacing(8) + + # Blend mode buttons — grouped with 1px gaps, connected border-radius + self._blend_grp = QtWidgets.QButtonGroup(self) + self._blend_btns = {} + btn_container = QtWidgets.QWidget() + btn_lay = QtWidgets.QVBoxLayout(btn_container) + btn_lay.setContentsMargins(0, 0, 0, 0) + btn_lay.setSpacing(0) + _blend_positions = ["first", "mid", "last"] + for i, (key, icon_name, tip) in enumerate( + [ + (COLOR_MOD_NORMAL, "normal", "Normal"), + (COLOR_MOD_DARKEN, "burn", "Burn"), + (COLOR_MOD_ADDITIVE, "dodge", "Dodge"), + ] + ): + btn = QtWidgets.QToolButton() + btn.setText({"normal": "◈", "burn": "🔥", "dodge": "🔍"}[icon_name]) + btn.setToolTip(tip) + btn.setProperty("grouppos", _blend_positions[i]) + _apply_icon(btn, icon_name) + btn.setCheckable(True) + btn.setFixedSize(30, 30) + self._blend_grp.addButton(btn) + self._blend_btns[key] = btn + btn_lay.addWidget(btn, alignment=QtCore.Qt.AlignHCenter) + if i < 2: + btn_lay.addSpacing(1) + self._blend_btns[COLOR_MOD_NORMAL].setChecked(True) + self._blend_grp.buttonClicked.connect(self._on_blend_clicked) + lay.addWidget(btn_container, alignment=QtCore.Qt.AlignHCenter) + lay.addStretch() + + def _on_blend_clicked(self, btn): + for key, b in self._blend_btns.items(): + if b is btn: + self.color_modifier_changed.emit(key) + return + + def set_color_modifier(self, mode): + btn = self._blend_btns.get(mode) + if btn: + btn.setChecked(True) + + def set_blend_mode_enabled(self, mode, enabled): + """Enable or disable a blend mode button (e.g. burn/dodge) by its key.""" + btn = self._blend_btns.get(mode) + if btn: + btn.setEnabled(enabled) + if not enabled and btn.isChecked(): + self._blend_btns[COLOR_MOD_NORMAL].setChecked(True) + self.color_modifier_changed.emit(COLOR_MOD_NORMAL) + + def set_size(self, v): + self._size.set_value(v) + + def set_opacity(self, v): + self._opacity.set_value(v) + + +class _ShapeOptionsPanel(QtWidgets.QWidget): + filled_changed = QtCore.Signal(bool) + size_changed = QtCore.Signal(int) + opacity_changed = QtCore.Signal(int) + + def __init__(self, parent=None): + super().__init__(parent) + lay = QtWidgets.QVBoxLayout(self) + lay.setContentsMargins(8, 8, 8, 8) + lay.setSpacing(0) + + self._filled = QtWidgets.QCheckBox("Filled") + self._filled.setChecked(False) + self._filled.toggled.connect(self.filled_changed) + _apply_check_icon(self._filled) + lay.addWidget(self._filled) + lay.addSpacing(8) + + self._size = _SliderSection("Size", 1, 100, 32) + self._size.value_changed.connect(self.size_changed) + lay.addWidget(self._size) + + lay.addSpacing(8) + lay.addWidget(_separator(30), alignment=QtCore.Qt.AlignHCenter) + lay.addSpacing(8) + + self._opacity = _SliderSection("Opacity", 0, 100, 50, suffix="%") + self._opacity.value_changed.connect(self.opacity_changed) + lay.addWidget(self._opacity) + + lay.addStretch() + + def set_filled(self, v): + self._filled.setChecked(v) + + def set_size(self, v): + self._size.set_value(v) + + def set_opacity(self, v): + self._opacity.set_value(v) + + +class _FontNameDelegate(QtWidgets.QStyledItemDelegate): + """Renders each font name item in its own typeface.""" + + def initStyleOption(self, option, index): + super().initStyleOption(option, index) + font_name = index.data() + if font_name: + f = QtGui.QFont(font_name) + pt = option.font.pointSize() + if pt > 0: + f.setPointSize(pt) + else: + px = option.font.pixelSize() + if px > 0: + f.setPixelSize(px) + option.font = f + + +class _TextOptionsPanel(QtWidgets.QWidget): + font_family_changed = QtCore.Signal(str) + font_size_changed = QtCore.Signal(str) # "small" | "medium" | "large" + font_bold_changed = QtCore.Signal(bool) + font_italic_changed = QtCore.Signal(bool) + font_underline_changed = QtCore.Signal(bool) + + def __init__(self, parent=None): + super().__init__(parent) + lay = QtWidgets.QVBoxLayout(self) + lay.setContentsMargins(8, 8, 8, 8) + lay.setSpacing(8) + + # Font family — system fonts + any fonts bundled in annotation-platform. + # Filter to smoothly scalable fonts only; bitmap fonts trigger Qt bearing warnings. + _load_bundled_fonts() + _db = QtGui.QFontDatabase() + self._font_combo = QtWidgets.QComboBox() + self._font_combo.setToolTip("Font") + self._font_combo.setItemDelegate(_FontNameDelegate(self._font_combo)) + for name in _db.families(): + if not name.startswith(".") and _db.isSmoothlyScalable(name): + self._font_combo.addItem(name) + self._font_combo.currentTextChanged.connect(self.font_family_changed) + self._font_combo.view().setMinimumWidth(160) + _apply_combo_style(self._font_combo) + lay.addWidget(self._font_combo) + + # S / M / L size combo + self._size_combo = QtWidgets.QComboBox() + self._size_combo.setToolTip("Size") + for label in ("S", "M", "L"): + self._size_combo.addItem(label) + self._size_combo.setCurrentText("M") + self._size_combo.currentTextChanged.connect( + lambda t: self.font_size_changed.emit({"S": "small", "M": "medium", "L": "large"}[t]) + ) + self._size_combo.view().setMinimumWidth(60) + _apply_combo_style(self._size_combo) + lay.addWidget(self._size_combo) + + lay.addWidget(_separator(30), alignment=QtCore.Qt.AlignHCenter) + + # B / I / U style toggles — full-width, stacked vertically + def _style_btn(label, tooltip): + btn = QtWidgets.QToolButton() + btn.setText(label) + btn.setCheckable(True) + btn.setToolTip(tooltip) + btn.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + btn.setFixedHeight(30) + return btn + + self._bold_btn = _style_btn("B", "Bold") + font_b = self._bold_btn.font() + font_b.setBold(True) + self._bold_btn.setFont(font_b) + _apply_icon(self._bold_btn, "bold") + self._bold_btn.toggled.connect(self.font_bold_changed) + lay.addWidget(self._bold_btn) + + self._italic_btn = _style_btn("I", "Italic") + font_i = self._italic_btn.font() + font_i.setItalic(True) + self._italic_btn.setFont(font_i) + _apply_icon(self._italic_btn, "italic") + self._italic_btn.toggled.connect(self.font_italic_changed) + lay.addWidget(self._italic_btn) + + self._underline_btn = _style_btn("U", "Underline") + font_u = self._underline_btn.font() + font_u.setUnderline(True) + self._underline_btn.setFont(font_u) + _apply_icon(self._underline_btn, "underline") + self._underline_btn.toggled.connect(self.font_underline_changed) + lay.addWidget(self._underline_btn) + + lay.addStretch() + + def set_font_family(self, name): + idx = self._font_combo.findText(name) + if idx >= 0: + self._font_combo.blockSignals(True) + self._font_combo.setCurrentIndex(idx) + self._font_combo.blockSignals(False) + + def set_font_size(self, size): + label = {"small": "S", "medium": "M", "large": "L"}.get(size, "M") + self._size_combo.blockSignals(True) + self._size_combo.setCurrentText(label) + self._size_combo.blockSignals(False) + + def set_bold(self, v): + self._bold_btn.blockSignals(True) + self._bold_btn.setChecked(v) + self._bold_btn.blockSignals(False) + + def set_italic(self, v): + self._italic_btn.blockSignals(True) + self._italic_btn.setChecked(v) + self._italic_btn.blockSignals(False) + + def set_underline(self, v): + self._underline_btn.blockSignals(True) + self._underline_btn.setChecked(v) + self._underline_btn.blockSignals(False) + + +# --------------------------------------------------------------------------- +# Floating colour picker popup +# --------------------------------------------------------------------------- + + +class ColourPickerPopup(QtWidgets.QFrame): + """Floating colour picker that appears to the right of the toolbar.""" + + colour_changed = QtCore.Signal(QtGui.QColor) + + def __init__(self, parent=None): + super().__init__(parent, QtCore.Qt.Tool | QtCore.Qt.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WA_ShowWithoutActivating) + self.setStyleSheet("QFrame { background: #1a1a1a; border: 1px solid #333333; border-radius: 4px; }") + lay = QtWidgets.QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + self._picker = ColourPickerSection() + self._picker.colour_changed.connect(self.colour_changed) + lay.addWidget(self._picker) + self.adjustSize() + + def show_near(self, anchor_widget): + """Position and show the popup to the right of anchor_widget.""" + pos = anchor_widget.mapToGlobal(QtCore.QPoint(anchor_widget.width() + 6, 0)) + self.move(pos) + self.show() + self.raise_() + + def set_colour(self, c): + self._picker.set_colour(c) + + +# --------------------------------------------------------------------------- +# Secondary panel +# --------------------------------------------------------------------------- + + +class AnnotateSecondaryPanel(QtWidgets.QWidget): + size_changed = QtCore.Signal(int) + opacity_changed = QtCore.Signal(int) + filled_changed = QtCore.Signal(bool) + color_modifier_changed = QtCore.Signal(str) + eraser_brush_changed = QtCore.Signal(str) + font_family_changed = QtCore.Signal(str) + font_size_changed = QtCore.Signal(str) + font_bold_changed = QtCore.Signal(bool) + font_italic_changed = QtCore.Signal(bool) + font_underline_changed = QtCore.Signal(bool) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("AnnotateSecondaryPanel") + self.setFixedWidth(80) + + lay = QtWidgets.QVBoxLayout(self) + lay.setContentsMargins(4, 8, 4, 8) + lay.setSpacing(0) + + self._stack = QtWidgets.QStackedWidget() + lay.addWidget(self._stack) + + # Page 0 — empty (cursor / eyedropper) + self._stack.addWidget(QtWidgets.QWidget()) + + # Page 1 — brush tools (arrow, line) + self._brush_panel = _SizeOpacityPanel() + self._brush_panel.size_changed.connect(self.size_changed) + self._brush_panel.opacity_changed.connect(self.opacity_changed) + self._stack.addWidget(self._brush_panel) + + # Page 2 — shape tools (rect, circle) + self._shape_panel = _ShapeOptionsPanel() + self._shape_panel.filled_changed.connect(self.filled_changed) + self._shape_panel.size_changed.connect(self.size_changed) + self._shape_panel.opacity_changed.connect(self.opacity_changed) + self._stack.addWidget(self._shape_panel) + + # Page 3 — text tool + self._text_panel = _TextOptionsPanel() + self._text_panel.font_family_changed.connect(self.font_family_changed) + self._text_panel.font_size_changed.connect(self.font_size_changed) + self._text_panel.font_bold_changed.connect(self.font_bold_changed) + self._text_panel.font_italic_changed.connect(self.font_italic_changed) + self._text_panel.font_underline_changed.connect(self.font_underline_changed) + self._stack.addWidget(self._text_panel) + + # Page 4 — pen and airbrush (size + opacity + blend mode buttons) + self._pen_panel = _PenPanel() + self._pen_panel.size_changed.connect(self.size_changed) + self._pen_panel.opacity_changed.connect(self.opacity_changed) + self._pen_panel.color_modifier_changed.connect(self.color_modifier_changed) + self._stack.addWidget(self._pen_panel) + + # Page 5 — eraser (brush type combo + size + opacity) + self._eraser_panel = _EraserPanel() + self._eraser_panel.eraser_brush_changed.connect(self.eraser_brush_changed) + self._eraser_panel.size_changed.connect(self.size_changed) + self._eraser_panel.opacity_changed.connect(self.opacity_changed) + self._stack.addWidget(self._eraser_panel) + + def set_page_for_tool(self, tool): + self._stack.setCurrentIndex(_TOOL_PAGE.get(tool, _PAGE_EMPTY)) + + def set_size(self, v): + self._brush_panel.set_size(v) + self._shape_panel.set_size(v) + self._pen_panel.set_size(v) + self._eraser_panel.set_size(v) + + def set_opacity(self, v): + self._brush_panel.set_opacity(v) + self._shape_panel.set_opacity(v) + self._pen_panel.set_opacity(v) + self._eraser_panel.set_opacity(v) + + def set_color_modifier(self, mode): + self._pen_panel.set_color_modifier(mode) + + def set_blend_mode_enabled(self, mode, enabled): + self._pen_panel.set_blend_mode_enabled(mode, enabled) + + def set_eraser_brush(self, brush): + self._eraser_panel.set_eraser_brush(brush) + + def set_soft_erase_enabled(self, enabled): + self._eraser_panel.set_soft_erase_enabled(enabled) + + def set_font_family(self, name): + self._text_panel.set_font_family(name) + + def set_font_size(self, size): + self._text_panel.set_font_size(size) + + def set_bold(self, v): + self._text_panel.set_bold(v) + + def set_italic(self, v): + self._text_panel.set_italic(v) + + def set_underline(self, v): + self._text_panel.set_underline(v) + + +# --------------------------------------------------------------------------- +# Tool strip +# --------------------------------------------------------------------------- + + +class AnnotateToolStrip(QtWidgets.QWidget): + """Narrow vertical column of annotation tool buttons.""" + + tool_changed = QtCore.Signal(str) + undo_requested = QtCore.Signal() + redo_requested = QtCore.Signal() + clear_requested = QtCore.Signal() + clear_all_requested = QtCore.Signal() + swatch_toggle_requested = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("AnnotateToolStrip") + self.setFixedWidth(30) + + lay = QtWidgets.QVBoxLayout(self) + lay.setContentsMargins(0, 8, 0, 8) + lay.setSpacing(0) + + self._group = QtWidgets.QButtonGroup(self) + self._buttons = {} + + def _add_tool(tool, grouppos="solo"): + btn = _tool_button(_TOOL_LABEL[tool], _TOOL_TOOLTIP[tool]) + _apply_icon(btn, tool) + if grouppos != "solo": + btn.setProperty("grouppos", grouppos) + self._buttons[tool] = btn + self._group.addButton(btn) + lay.addWidget(btn) + + def _group_of(tools): + """Add a visually-connected group of tool buttons (1px gap between them).""" + for i, tool in enumerate(tools): + if i == 0: + pos = "first" + elif i == len(tools) - 1: + pos = "last" + else: + pos = "mid" + _add_tool(tool, grouppos=pos) + if i < len(tools) - 1: + lay.addSpacing(1) + + # Cursor — standalone + _add_tool(TOOL_CURSOR) + + lay.addSpacing(1) + + # Drawing group: pen + airbrush + eraser + _group_of([TOOL_PEN, TOOL_AIRBRUSH, TOOL_ERASER]) + + lay.addSpacing(1) + + # Shapes group: rect + circle + arrow + line + _group_of([TOOL_RECT, TOOL_CIRCLE, TOOL_ARROW, TOOL_LINE]) + + lay.addSpacing(1) + + # Text and eyedropper — standalone + _add_tool(TOOL_TEXT) + lay.addSpacing(1) + _add_tool(TOOL_EYEDROPPER) + + # Fixed gap + divider + swatch (NOT floating — swatch follows tools) + lay.addSpacing(4) + lay.addWidget(_separator()) + lay.addSpacing(4) + + self._swatch = ColourSwatch() + lay.addWidget(self._swatch, alignment=QtCore.Qt.AlignHCenter) + self._swatch.swatch_clicked.connect(self.swatch_toggle_requested) + + # All remaining space goes here, pushing actions to the bottom + lay.addStretch() + + lay.addWidget(_separator()) + lay.addSpacing(1) + + self._undo_btn = _tool_button("↩", "Undo", checkable=False) + self._undo_btn.setObjectName("ActionBtn") + _apply_icon(self._undo_btn, "undo") + self._undo_btn.setEnabled(False) + self._undo_btn.clicked.connect(self.undo_requested) + lay.addWidget(self._undo_btn) + + lay.addSpacing(1) + + self._redo_btn = _tool_button("↪", "Redo", checkable=False) + self._redo_btn.setObjectName("ActionBtn") + _apply_icon(self._redo_btn, "redo") + self._redo_btn.setEnabled(False) + self._redo_btn.clicked.connect(self.redo_requested) + lay.addWidget(self._redo_btn) + + lay.addSpacing(1) + + self._clear_btn = _tool_button("⊗", "Clear Frame", checkable=False) + self._clear_btn.setObjectName("ActionBtn") + _apply_icon(self._clear_btn, "clear") + self._clear_btn.clicked.connect(self._on_clear_clicked) + lay.addWidget(self._clear_btn) + + self._group.buttonClicked.connect(self._on_tool_clicked) + self._buttons[TOOL_PEN].setChecked(True) + + def _on_clear_clicked(self): + menu = QtWidgets.QMenu(self) + menu.setStyleSheet(_MENU_SS) + menu.addAction("Clear Frame", self.clear_requested.emit) + menu.addAction("Clear All Frames on Timeline", self._on_clear_all_confirmed) + pos = self._clear_btn.mapToGlobal(QtCore.QPoint(self._clear_btn.width() + 2, 0)) + menu.exec_(pos) + + def _on_clear_all_confirmed(self): + dlg = QtWidgets.QMessageBox(self) + dlg.setWindowTitle("Clear Annotations") + dlg.setText("Clear all annotations from the current timeline?") + dlg.setStandardButtons(QtWidgets.QMessageBox.Cancel | QtWidgets.QMessageBox.Ok) + dlg.setDefaultButton(QtWidgets.QMessageBox.Cancel) + if dlg.exec_() == QtWidgets.QMessageBox.Ok: + self.clear_all_requested.emit() + + def _on_tool_clicked(self, btn): + for tool, b in self._buttons.items(): + if b is btn: + self.tool_changed.emit(tool) + return + + def set_active_tool(self, tool): + btn = self._buttons.get(tool) + if btn: + btn.setChecked(True) + + def set_tool_enabled(self, tool, enabled): + """Enable or disable a tool button. If the tool is active when disabled, switches to pen.""" + btn = self._buttons.get(tool) + if btn: + btn.setEnabled(enabled) + if not enabled and btn.isChecked(): + self._buttons[TOOL_PEN].setChecked(True) + self.tool_changed.emit(TOOL_PEN) + + def set_undo_enabled(self, enabled): + self._undo_btn.setEnabled(enabled) + + def set_redo_enabled(self, enabled): + self._redo_btn.setEnabled(enabled) + + def set_colour(self, colour): + """Update the swatch colour display.""" + self._swatch.set_colour(colour) + + +# --------------------------------------------------------------------------- +# Top-level widget and dock +# --------------------------------------------------------------------------- + + +class AnnotateToolbarWidget(QtWidgets.QWidget): + """Combined header + strip + secondary panel. All signals bubble up from children.""" + + tool_changed = QtCore.Signal(str) + colour_changed = QtCore.Signal(QtGui.QColor) + size_changed = QtCore.Signal(int) + opacity_changed = QtCore.Signal(int) + filled_changed = QtCore.Signal(bool) + color_modifier_changed = QtCore.Signal(str) + eraser_brush_changed = QtCore.Signal(str) + font_family_changed = QtCore.Signal(str) + font_size_changed = QtCore.Signal(str) + font_bold_changed = QtCore.Signal(bool) + font_italic_changed = QtCore.Signal(bool) + font_underline_changed = QtCore.Signal(bool) + undo_requested = QtCore.Signal() + redo_requested = QtCore.Signal() + clear_requested = QtCore.Signal() + clear_all_requested = QtCore.Signal() + close_requested = QtCore.Signal() + dock_requested = QtCore.Signal() + swatch_toggle_requested = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setStyleSheet(_STRIP_SS + _PANEL_SS) + + outer_lay = QtWidgets.QVBoxLayout(self) + outer_lay.setContentsMargins(0, 0, 0, 0) + outer_lay.setSpacing(0) + + # Header bar + self._header = _ToolbarHeader() + outer_lay.addWidget(self._header) + + # Strip + panel row + body = QtWidgets.QWidget() + lay = QtWidgets.QHBoxLayout(body) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + + self._strip = AnnotateToolStrip() + lay.addWidget(self._strip) + + self._panel = AnnotateSecondaryPanel() + lay.addWidget(self._panel) + + outer_lay.addWidget(body) + + # Floating colour picker popup (no parent — truly floating) + self._picker_popup = ColourPickerPopup() + self._picker_popup.colour_changed.connect(self._on_colour_changed) + + # Wire header signals + self._header.close_requested.connect(self.close_requested) + self._header.dock_requested.connect(self.dock_requested) + + # Wire strip signals + self._strip.tool_changed.connect(self._on_tool_changed) + self._strip.undo_requested.connect(self.undo_requested) + self._strip.redo_requested.connect(self.redo_requested) + self._strip.clear_requested.connect(self.clear_requested) + self._strip.clear_all_requested.connect(self.clear_all_requested) + self._strip.swatch_toggle_requested.connect(self._on_swatch_toggle) + + # Wire panel signals (no colour_changed — that comes from the popup now) + self._panel.size_changed.connect(self.size_changed) + self._panel.opacity_changed.connect(self.opacity_changed) + self._panel.filled_changed.connect(self.filled_changed) + self._panel.color_modifier_changed.connect(self.color_modifier_changed) + self._panel.eraser_brush_changed.connect(self.eraser_brush_changed) + self._panel.font_family_changed.connect(self.font_family_changed) + self._panel.font_size_changed.connect(self.font_size_changed) + self._panel.font_bold_changed.connect(self.font_bold_changed) + self._panel.font_italic_changed.connect(self.font_italic_changed) + self._panel.font_underline_changed.connect(self.font_underline_changed) + + # Set initial page + self._panel.set_page_for_tool(TOOL_PEN) + + def _on_tool_changed(self, tool): + self._panel.set_page_for_tool(tool) + self.tool_changed.emit(tool) + + def _on_swatch_toggle(self): + if self._picker_popup.isVisible(): + self._picker_popup.hide() + else: + self._picker_popup.show_near(self._strip._swatch) + self.swatch_toggle_requested.emit() + + def hide_popups(self): + self._picker_popup.hide() + + def _on_colour_changed(self, colour): + self._strip.set_colour(colour) + self.colour_changed.emit(colour) + + def set_colour(self, colour): + """Programmatically set the active colour (e.g. on tool switch).""" + self._strip.set_colour(colour) + self._picker_popup.set_colour(colour) + + def set_size(self, v): + self._panel.set_size(v) + + def set_opacity(self, v): + self._panel.set_opacity(v) + + # Passthrough accessors so the mode can read current UI state + @property + def panel(self): + return self._panel + + @property + def strip(self): + return self._strip + + def set_undo_enabled(self, v): + self._strip.set_undo_enabled(v) + + def set_redo_enabled(self, v): + self._strip.set_redo_enabled(v) + + def set_tool_enabled(self, tool, enabled): + self._strip.set_tool_enabled(tool, enabled) + + def set_blend_mode_enabled(self, mode, enabled): + self._panel.set_blend_mode_enabled(mode, enabled) + + def set_soft_erase_enabled(self, enabled): + self._panel.set_soft_erase_enabled(enabled) + + +class AnnotateToolbarDockWidget(QtWidgets.QDockWidget): + """QDockWidget wrapper for the annotation toolbar.""" + + def __init__(self, parent=None): + super().__init__("Annotations", parent) + self.setFeatures(QtWidgets.QDockWidget.DockWidgetMovable | QtWidgets.QDockWidget.DockWidgetFloatable) + self.setAllowedAreas(QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea) + # Show tooltips even when this window is not the active window (e.g. when floating + # or when RV's main viewport has focus). + self.setAttribute(QtCore.Qt.WA_AlwaysShowToolTips) + self._widget = AnnotateToolbarWidget() + self.setWidget(self._widget) + + @property + def toolbar_widget(self): + return self._widget diff --git a/src/plugins/rv-packages/annotate_beta/icon_airbrush.svg b/src/plugins/rv-packages/annotate_beta/icon_airbrush.svg new file mode 100644 index 000000000..be6c4f162 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_airbrush.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_arrow.svg b/src/plugins/rv-packages/annotate_beta/icon_arrow.svg new file mode 100644 index 000000000..89bd235c4 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_arrow.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_bold.svg b/src/plugins/rv-packages/annotate_beta/icon_bold.svg new file mode 100644 index 000000000..42bd72100 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_bold.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_brush.svg b/src/plugins/rv-packages/annotate_beta/icon_brush.svg new file mode 100644 index 000000000..d6444f202 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_brush.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_burn.svg b/src/plugins/rv-packages/annotate_beta/icon_burn.svg new file mode 100644 index 000000000..10ad55588 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_burn.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_caret_down.svg b/src/plugins/rv-packages/annotate_beta/icon_caret_down.svg new file mode 100644 index 000000000..c747e8d70 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_caret_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_caret_down_active.svg b/src/plugins/rv-packages/annotate_beta/icon_caret_down_active.svg new file mode 100644 index 000000000..1f81e0351 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_caret_down_active.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_check.svg b/src/plugins/rv-packages/annotate_beta/icon_check.svg new file mode 100644 index 000000000..ac2085c63 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_circle.svg b/src/plugins/rv-packages/annotate_beta/icon_circle.svg new file mode 100644 index 000000000..3067f90eb --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_clear.svg b/src/plugins/rv-packages/annotate_beta/icon_clear.svg new file mode 100644 index 000000000..5c9b163aa --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_clear.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_cursor.svg b/src/plugins/rv-packages/annotate_beta/icon_cursor.svg new file mode 100644 index 000000000..1411e9814 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_cursor.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_dodge.svg b/src/plugins/rv-packages/annotate_beta/icon_dodge.svg new file mode 100644 index 000000000..6b0c2d4a9 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_dodge.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_erase_circle.svg b/src/plugins/rv-packages/annotate_beta/icon_erase_circle.svg new file mode 100644 index 000000000..69708a924 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_erase_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_erase_gauss.svg b/src/plugins/rv-packages/annotate_beta/icon_erase_gauss.svg new file mode 100644 index 000000000..ce5185e51 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_erase_gauss.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_eraser.svg b/src/plugins/rv-packages/annotate_beta/icon_eraser.svg new file mode 100644 index 000000000..1fb156baf --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_eraser.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_eyedropper.svg b/src/plugins/rv-packages/annotate_beta/icon_eyedropper.svg new file mode 100644 index 000000000..f7e741f88 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_eyedropper.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_italic.svg b/src/plugins/rv-packages/annotate_beta/icon_italic.svg new file mode 100644 index 000000000..7d40c98d8 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_italic.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_line.svg b/src/plugins/rv-packages/annotate_beta/icon_line.svg new file mode 100644 index 000000000..e825fd34f --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_line.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_normal.svg b/src/plugins/rv-packages/annotate_beta/icon_normal.svg new file mode 100644 index 000000000..19cb22bdf --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_normal.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_pen.svg b/src/plugins/rv-packages/annotate_beta/icon_pen.svg new file mode 100644 index 000000000..488b09a56 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_pen.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_rect.svg b/src/plugins/rv-packages/annotate_beta/icon_rect.svg new file mode 100644 index 000000000..58e8d1a3e --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_rect.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_redo.svg b/src/plugins/rv-packages/annotate_beta/icon_redo.svg new file mode 100644 index 000000000..48f69c178 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_redo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_text.svg b/src/plugins/rv-packages/annotate_beta/icon_text.svg new file mode 100644 index 000000000..2ec6dd83c --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_text.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_underline.svg b/src/plugins/rv-packages/annotate_beta/icon_underline.svg new file mode 100644 index 000000000..deca10916 --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_underline.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/plugins/rv-packages/annotate_beta/icon_undo.svg b/src/plugins/rv-packages/annotate_beta/icon_undo.svg new file mode 100644 index 000000000..02fc5f39e --- /dev/null +++ b/src/plugins/rv-packages/annotate_beta/icon_undo.svg @@ -0,0 +1,3 @@ + + +