This commit is contained in:
2026-02-24 22:57:14 +00:00
commit c9af844428
27 changed files with 1678 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
cmake_minimum_required(VERSION 3.23)
project(cppbridge LANGUAGES CXX)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
find_package(Qt6 REQUIRED COMPONENTS Core Qml Quick)
find_package(OpenCV 4 REQUIRED COMPONENTS core imgcodecs imgproc)
qt_standard_project_setup(REQUIRES 6.5)
qt_add_qml_module(cppbridgeplugin
URI cppbridge
VERSION 1.0
OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/../package/contents/ui/cppbridge"
SOURCES
src/clockimageprovider.hpp
src/clockimageprovider.cpp
src/config.hpp
src/renderer.hpp
src/renderer.cpp
src/timebridge.h
src/timebridge.cpp
)
target_include_directories(cppbridgeplugin PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/src"
${OpenCV_INCLUDE_DIRS}
)
target_link_libraries(cppbridgeplugin PRIVATE
Qt6::Core
Qt6::Qml
Qt6::Quick
opencv_core
opencv_imgcodecs
opencv_imgproc
)
set_target_properties(cppbridgeplugin PROPERTIES
CXX_STANDARD 20
CXX_STANDARD_REQUIRED ON
)
+28
View File
@@ -0,0 +1,28 @@
{
"version": 6,
"cmakeMinimumRequired": {
"major": 3,
"minor": 23,
"patch": 0
},
"configurePresets": [
{
"name": "clang-default",
"displayName": "Clang Default",
"description": "Configure build/cpp with clang/clang++ and export compile commands.",
"generator": "Unix Makefiles",
"binaryDir": "${sourceDir}/../build/cpp",
"cacheVariables": {
"CMAKE_C_COMPILER": "clang",
"CMAKE_CXX_COMPILER": "clang++",
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON"
}
}
],
"buildPresets": [
{
"name": "clang-default",
"configurePreset": "clang-default"
}
]
}
+32
View File
@@ -0,0 +1,32 @@
#include "clockimageprovider.hpp"
#include <QMutexLocker>
ClockImageProvider::ClockImageProvider()
: QQuickImageProvider(QQuickImageProvider::Image) {}
QImage ClockImageProvider::requestImage(const QString&, QSize* size,
const QSize& requestedSize) {
QImage frame;
{
QMutexLocker locker(&mutex_);
frame = image_;
}
if (size != nullptr) {
*size = frame.size();
}
if (!frame.isNull() && requestedSize.width() > 0 &&
requestedSize.height() > 0) {
return frame.scaled(requestedSize, Qt::KeepAspectRatio,
Qt::SmoothTransformation);
}
return frame;
}
void ClockImageProvider::setImage(const QImage& image) {
QMutexLocker locker(&mutex_);
image_ = image;
}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <QImage>
#include <QMutex>
#include <QQuickImageProvider>
class ClockImageProvider : public QQuickImageProvider {
public:
ClockImageProvider();
QImage requestImage(const QString& id, QSize* size,
const QSize& requestedSize) override;
void setImage(const QImage& image);
private:
QMutex mutex_;
QImage image_;
};
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <opencv2/core.hpp>
// file full of magic numbers
// most are hand tuned based on the cat image I have
namespace Config {
const cv::Point2f CLOCK_CENTER(300.0f, 286.0f);
const double HAND_SCALE = 0.22;
const double HOUR_HAND_SCALE = HAND_SCALE * 0.7;
const double MINUTE_HAND_SCALE = HAND_SCALE * 0.7;
const cv::Point2f HOUR_HAND_PIVOT_FRACTION(0.126f, 0.502f);
const cv::Point2f MINUTE_HAND_PIVOT_FRACTION(0.101f, 0.488f);
// Asset convention: unrotated hand points right (+X).
// Clock convention: 12:00 points up (-Y), hence +90 degrees.
const double HAND_BASE_ANGLE_OFFSET = 90.0;
} // namespace Config
+112
View File
@@ -0,0 +1,112 @@
#include "renderer.hpp"
#include <iostream>
#include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp>
#include "config.hpp"
Renderer::Renderer() {}
bool Renderer::load_assets(const std::string& bg_path,
const std::string& hour_hand_path,
const std::string& minute_hand_path) {
background_ = cv::imread(bg_path, cv::IMREAD_UNCHANGED);
if (background_.empty()) {
std::cerr << "failed to load background: " << bg_path << std::endl;
return false;
}
if (background_.channels() != 4) {
std::cerr << "expected rgba png for background";
return false;
}
// Load hands with alpha channel.
hour_hand_ = cv::imread(hour_hand_path, cv::IMREAD_UNCHANGED);
if (hour_hand_.empty()) {
std::cerr << "failed to load hour hand: " << hour_hand_path
<< std::endl;
return false;
}
minute_hand_ = cv::imread(minute_hand_path, cv::IMREAD_UNCHANGED);
if (minute_hand_.empty()) {
std::cerr << "failed to load minute hand: " << minute_hand_path
<< std::endl;
return false;
}
if (hour_hand_.channels() != 4 || minute_hand_.channels() != 4) {
std::cerr << "expected rgba png for hands" << std::endl;
return false;
}
return true;
}
cv::Mat Renderer::render(double hour_angle, double minute_angle) {
auto clock_to_cv_rotation = [](double clock_angle) {
return Config::HAND_BASE_ANGLE_OFFSET - clock_angle;
};
cv::Mat warped_hour = transform_hand(
hour_hand_, clock_to_cv_rotation(hour_angle), Config::HOUR_HAND_SCALE,
Config::HOUR_HAND_PIVOT_FRACTION);
cv::Mat warped_minute = transform_hand(
minute_hand_, clock_to_cv_rotation(minute_angle),
Config::MINUTE_HAND_SCALE, Config::MINUTE_HAND_PIVOT_FRACTION);
cv::Mat keyframe = background_.clone();
// Layer order matters: hour underneath minute.
blend_overlay(keyframe, warped_hour);
blend_overlay(keyframe, warped_minute);
return keyframe;
}
cv::Mat Renderer::transform_hand(const cv::Mat& hand_sprite, double angle_deg,
double scale,
const cv::Point2f& pivot_fraction) {
cv::Mat oriented_sprite;
cv::flip(hand_sprite, oriented_sprite, 0);
if (scale <= 0.0) {
return cv::Mat::zeros(background_.size(), CV_8UC4);
}
cv::Point2f pivot(oriented_sprite.cols * pivot_fraction.x,
oriented_sprite.rows * pivot_fraction.y);
cv::Mat rot_mat = cv::getRotationMatrix2D(pivot, angle_deg, scale);
rot_mat.at<double>(0, 2) += Config::CLOCK_CENTER.x - pivot.x;
rot_mat.at<double>(1, 2) += Config::CLOCK_CENTER.y - pivot.y;
cv::Mat overlay = cv::Mat::zeros(background_.size(), CV_8UC4);
cv::warpAffine(oriented_sprite, overlay, rot_mat, background_.size(),
cv::INTER_CUBIC, cv::BORDER_CONSTANT,
cv::Scalar(0, 0, 0, 0));
return overlay;
}
void Renderer::blend_overlay(cv::Mat& bg, const cv::Mat& fg) {
// Blend hand RGB onto background RGB using hand alpha
for (int y = 0; y < fg.rows; ++y) {
const cv::Vec4b* fg_ptr = fg.ptr<cv::Vec4b>(y);
cv::Vec4b* bg_ptr = bg.ptr<cv::Vec4b>(y);
for (int x = 0; x < fg.cols; ++x) {
const cv::Vec4b& fg_pixel = fg_ptr[x];
const double alpha = fg_pixel[3] / 255.0;
if (alpha > 0.0) {
const cv::Vec4b bg_pixel = bg_ptr[x];
for (int c = 0; c < 3; ++c) {
bg_ptr[x][c] = static_cast<uchar>(
fg_pixel[c] * alpha + bg_pixel[c] * (1.0 - alpha));
}
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <opencv2/core.hpp>
#include <string>
class Renderer {
public:
Renderer();
// Load assets from disk. Returns false if any fail to load.
bool load_assets(const std::string& bg_path,
const std::string& hour_hand_path,
const std::string& minute_hand_path);
// Composites the hands onto the background at the given angles (degrees).
// Returns the result image.
cv::Mat render(double hour_angle, double minute_angle);
private:
cv::Mat background_;
cv::Mat hour_hand_;
cv::Mat minute_hand_;
// Helpers
cv::Mat transform_hand(const cv::Mat& hand_sprite, double angle_deg,
double scale, const cv::Point2f& pivot_fraction);
void blend_overlay(cv::Mat& bg, const cv::Mat& fg);
};
+216
View File
@@ -0,0 +1,216 @@
#include "timebridge.h"
#include <QQmlEngine>
#include <QTime>
#include <QUuid>
#include <opencv2/imgproc.hpp>
#include "clockimageprovider.hpp"
#include "renderer.hpp"
namespace {
constexpr int kMillisPerMinute = 60 * 1000;
}
ClockBridge::ClockBridge(QObject* parent) : QObject(parent) {
updateTimer_.setSingleShot(true);
connect(&updateTimer_, &QTimer::timeout, this,
&ClockBridge::onUpdateTimerTimeout);
}
ClockBridge::~ClockBridge() {
updateTimer_.stop();
if (renderer_ != nullptr) {
delete renderer_;
renderer_ = nullptr;
}
}
QString ClockBridge::imageSource() const { return imageSource_; }
bool ClockBridge::ready() const { return ready_; }
QString ClockBridge::errorString() const { return errorString_; }
bool ClockBridge::initialize(const QUrl& bg, const QUrl& hour,
const QUrl& minute) {
updateTimer_.stop();
initialized_ = false;
setReady(false);
const QString bgPath = toLocalPath(bg);
const QString hourPath = toLocalPath(hour);
const QString minutePath = toLocalPath(minute);
if (bgPath.isEmpty() || hourPath.isEmpty() || minutePath.isEmpty()) {
setErrorString(QStringLiteral("Clock assets must be local file URLs."));
return false;
}
if (!ensureImageProvider()) {
return false;
}
if (renderer_ == nullptr) {
renderer_ = new Renderer();
}
if (!renderer_->load_assets(bgPath.toStdString(), hourPath.toStdString(),
minutePath.toStdString())) {
setErrorString(QStringLiteral("Failed to load clock assets."));
return false;
}
initialized_ = true;
if (!renderForCurrentTime()) {
initialized_ = false;
return false;
}
scheduleNextMinuteTick();
return true;
}
void ClockBridge::refreshNow() {
if (!initialized_) {
setErrorString(QStringLiteral("ClockBridge is not initialized."));
setReady(false);
return;
}
renderForCurrentTime();
scheduleNextMinuteTick();
}
void ClockBridge::onUpdateTimerTimeout() {
if (!initialized_) {
return;
}
renderForCurrentTime();
scheduleNextMinuteTick();
}
bool ClockBridge::ensureImageProvider() {
if (imageProvider_ != nullptr) {
return true;
}
QQmlEngine* engine = qmlEngine(this);
if (engine == nullptr) {
setErrorString(QStringLiteral("ClockBridge has no active QQmlEngine."));
return false;
}
providerId_ = QStringLiteral("catclock_%1")
.arg(QUuid::createUuid().toString(QUuid::Id128));
if (engine->imageProvider(providerId_) != nullptr) {
setErrorString(QStringLiteral("Image provider ID collision."));
return false;
}
imageProvider_ = new ClockImageProvider();
engine->addImageProvider(providerId_, imageProvider_);
return true;
}
bool ClockBridge::renderForCurrentTime() {
if (renderer_ == nullptr || imageProvider_ == nullptr) {
setErrorString(QStringLiteral("ClockBridge is not initialized."));
setReady(false);
return false;
}
const QTime now = QTime::currentTime();
const int minute = now.minute();
const int hour = now.hour();
const double minuteAngle = minute * 6.0;
const double hourAngle = (hour % 12) * 30.0 + minute * 0.5;
cv::Mat frame = renderer_->render(hourAngle, minuteAngle);
if (frame.empty()) {
setErrorString(QStringLiteral("Renderer returned an empty frame."));
setReady(false);
return false;
}
cv::Mat rgbaFrame;
cv::cvtColor(frame, rgbaFrame, cv::COLOR_BGRA2RGBA);
QImage image(rgbaFrame.data, rgbaFrame.cols, rgbaFrame.rows,
static_cast<int>(rgbaFrame.step), QImage::Format_RGBA8888);
imageProvider_->setImage(image.copy());
setErrorString(QString());
setReady(true);
bumpFrameRevision();
return true;
}
void ClockBridge::scheduleNextMinuteTick() {
if (!initialized_) {
return;
}
const QTime now = QTime::currentTime();
int delayMs = kMillisPerMinute - (now.second() * 1000 + now.msec());
if (delayMs <= 0 || delayMs > kMillisPerMinute) {
delayMs = kMillisPerMinute;
}
updateTimer_.start(delayMs);
}
void ClockBridge::setReady(bool value) {
if (ready_ == value) {
return;
}
ready_ = value;
emit readyChanged();
}
void ClockBridge::setErrorString(const QString& value) {
if (errorString_ == value) {
return;
}
errorString_ = value;
emit errorStringChanged();
}
QString ClockBridge::toLocalPath(const QUrl& url) const {
if (url.isEmpty()) {
return QString();
}
if (url.isLocalFile()) {
return url.toLocalFile();
}
if (url.scheme().isEmpty()) {
return url.toString();
}
return QString();
}
QString ClockBridge::buildImageSource() const {
if (providerId_.isEmpty()) {
return QString();
}
return QStringLiteral("image://%1/frame?rev=%2")
.arg(providerId_)
.arg(frameRevision_);
}
// to signal image inddeed changed even if the path is same
// we update revision
void ClockBridge::bumpFrameRevision() {
++frameRevision_;
const QString nextSource = buildImageSource();
if (imageSource_ == nextSource) {
return;
}
imageSource_ = nextSource;
emit imageSourceChanged();
}
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include <qqmlintegration.h>
#include <QObject>
#include <QString>
#include <QTimer>
#include <QUrl>
class ClockImageProvider;
class Renderer;
class ClockBridge : public QObject {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QString imageSource READ imageSource NOTIFY imageSourceChanged)
Q_PROPERTY(bool ready READ ready NOTIFY readyChanged)
Q_PROPERTY(QString errorString READ errorString NOTIFY errorStringChanged)
public:
explicit ClockBridge(QObject* parent = nullptr);
~ClockBridge() override;
QString imageSource() const;
bool ready() const;
QString errorString() const;
Q_INVOKABLE bool initialize(const QUrl& bg, const QUrl& hour,
const QUrl& minute);
Q_INVOKABLE void refreshNow();
signals:
void imageSourceChanged();
void readyChanged();
void errorStringChanged();
private slots:
void onUpdateTimerTimeout();
private:
bool ensureImageProvider();
bool renderForCurrentTime();
void scheduleNextMinuteTick();
void setReady(bool value);
void setErrorString(const QString& value);
QString toLocalPath(const QUrl& url) const;
QString buildImageSource() const;
void bumpFrameRevision();
Renderer* renderer_ = nullptr;
ClockImageProvider* imageProvider_ = nullptr;
QString providerId_;
QString imageSource_;
QString errorString_;
bool ready_ = false;
bool initialized_ = false;
int frameRevision_ = 0;
QTimer updateTimer_;
};