导出数据转spine
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
#include "ui/MainWindow.hpp"
|
||||
|
||||
#include "atlas/AtlasParser.hpp"
|
||||
#include "conversion/ConversionJob.hpp"
|
||||
#include "editor/SpineEditorLocator.hpp"
|
||||
#include "editor/SpineVersionAvailability.hpp"
|
||||
#include "scan/SpineDataVersionDetector.hpp"
|
||||
#include "ui/SpinePreviewWidget.hpp"
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QCheckBox>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QDragEnterEvent>
|
||||
#include <QDropEvent>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QMimeData>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QSettings>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
namespace dts {
|
||||
namespace {
|
||||
|
||||
std::filesystem::path localPath(const QString& value) {
|
||||
#if defined(_WIN32)
|
||||
return std::filesystem::path(value.toStdWString());
|
||||
#else
|
||||
return std::filesystem::path(value.toStdString());
|
||||
#endif
|
||||
}
|
||||
|
||||
QString platformLibraryName(const QString& line) {
|
||||
#if defined(_WIN32)
|
||||
return QStringLiteral("dts-runtime-%1.dll").arg(line);
|
||||
#elif defined(__APPLE__)
|
||||
return QStringLiteral("libdts-runtime-%1.dylib").arg(line);
|
||||
#else
|
||||
return QStringLiteral("libdts-runtime-%1.so").arg(line);
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
|
||||
setupUi();
|
||||
setAcceptDrops(true);
|
||||
setWindowTitle(tr("DateToSpine"));
|
||||
resize(960, 680);
|
||||
}
|
||||
|
||||
void MainWindow::openResourcePath(const QString& path) {
|
||||
addInput(path);
|
||||
}
|
||||
|
||||
void MainWindow::setupUi() {
|
||||
auto* root = new QWidget;
|
||||
auto* layout = new QVBoxLayout(root);
|
||||
layout->setContentsMargins(28, 24, 28, 24);
|
||||
layout->setSpacing(16);
|
||||
|
||||
auto* top = new QHBoxLayout;
|
||||
auto* title = new QLabel(tr("DateToSpine"));
|
||||
title->setObjectName(QStringLiteral("title"));
|
||||
availabilityLabel_ = new QLabel;
|
||||
availabilityLabel_->setAlignment(Qt::AlignCenter);
|
||||
availabilityLabel_->setMinimumWidth(190);
|
||||
availabilityLabel_->setVisible(false);
|
||||
resourceCombo_ = new QComboBox;
|
||||
resourceCombo_->setMinimumWidth(260);
|
||||
resourceCombo_->setVisible(false);
|
||||
auto* addButton = new QPushButton(tr("选择资源"));
|
||||
addButton->setObjectName(QStringLiteral("quietButton"));
|
||||
top->addWidget(title);
|
||||
top->addStretch();
|
||||
top->addWidget(availabilityLabel_);
|
||||
top->addStretch();
|
||||
top->addWidget(resourceCombo_);
|
||||
top->addWidget(addButton);
|
||||
layout->addLayout(top);
|
||||
|
||||
previewWidget_ = new SpinePreviewWidget;
|
||||
previewWidget_->setObjectName(QStringLiteral("preview"));
|
||||
layout->addWidget(previewWidget_, 1);
|
||||
|
||||
auto* controls = new QHBoxLayout;
|
||||
animationCombo_ = new QComboBox;
|
||||
animationCombo_->setMinimumWidth(220);
|
||||
animationCombo_->setEnabled(false);
|
||||
statusLabel_ = new QLabel;
|
||||
statusLabel_->setObjectName(QStringLiteral("status"));
|
||||
unpremultiplyCheck_ = new QCheckBox(tr("去除预乘 Alpha"));
|
||||
unpremultiplyCheck_->setToolTip(tr("由 Spine 官方纹理解包器转换为普通透明图片"));
|
||||
convertButton_ = new QPushButton(tr("一键转换为 .spine"));
|
||||
convertButton_->setObjectName(QStringLiteral("primaryButton"));
|
||||
convertButton_->setEnabled(false);
|
||||
controls->addWidget(new QLabel(tr("动画")));
|
||||
controls->addWidget(animationCombo_);
|
||||
controls->addWidget(statusLabel_, 1);
|
||||
controls->addWidget(unpremultiplyCheck_);
|
||||
controls->addWidget(convertButton_);
|
||||
layout->addLayout(controls);
|
||||
|
||||
setCentralWidget(root);
|
||||
setStyleSheet(QStringLiteral(R"(
|
||||
QMainWindow, QWidget { background: #17191e; color: #f3f4f6; font-size: 14px; }
|
||||
QLabel#title { font-size: 22px; font-weight: 650; }
|
||||
QLabel#preview { background: #101217; border: 2px dashed #343944; border-radius: 14px; color: #a7adb8; }
|
||||
QLabel#status { color: #9299a5; padding-left: 8px; }
|
||||
QComboBox { background: #242832; border: 1px solid #3a404c; border-radius: 8px; padding: 8px 12px; }
|
||||
QPushButton { border-radius: 8px; padding: 9px 16px; }
|
||||
QPushButton#quietButton { background: #292d36; border: 1px solid #3d4350; }
|
||||
QPushButton#primaryButton { background: #6e5eea; border: none; font-weight: 650; padding: 11px 20px; }
|
||||
QPushButton#primaryButton:disabled { background: #343743; color: #777d89; }
|
||||
QCheckBox { spacing: 8px; color: #c9ced8; }
|
||||
QCheckBox::indicator { width: 18px; height: 18px; }
|
||||
)"));
|
||||
showEmptyState();
|
||||
|
||||
connect(addButton, &QPushButton::clicked, this, &MainWindow::chooseResources);
|
||||
connect(resourceCombo_, &QComboBox::currentIndexChanged,
|
||||
this, &MainWindow::resourceChanged);
|
||||
connect(animationCombo_, &QComboBox::currentTextChanged,
|
||||
this, &MainWindow::animationChanged);
|
||||
connect(convertButton_, &QPushButton::clicked, this, &MainWindow::convertCurrent);
|
||||
previewTimer_ = new QTimer(this);
|
||||
previewTimer_->setInterval(16);
|
||||
connect(previewTimer_, &QTimer::timeout, this, &MainWindow::advancePreview);
|
||||
}
|
||||
|
||||
void MainWindow::showEmptyState() {
|
||||
previewWidget_->resetViewport();
|
||||
previewWidget_->setMessage(
|
||||
tr("把 .json / .skel、.atlas 和图片拖到这里\n\n也可以拖入整个资源文件夹"));
|
||||
statusLabel_->clear();
|
||||
}
|
||||
|
||||
void MainWindow::chooseResources() {
|
||||
const auto paths = QFileDialog::getOpenFileNames(this, tr("选择导出资源"), {},
|
||||
tr("Spine 资源 (*.json *.skel *.atlas *.png *.jpg *.jpeg *.webp);;所有文件 (*)"));
|
||||
for (const auto& path : paths) addInput(path);
|
||||
}
|
||||
|
||||
void MainWindow::addInput(const QString& path) {
|
||||
const QFileInfo info(path);
|
||||
const auto scanDirectory = [this](const QString& directory, bool recursive) {
|
||||
QDirIterator iterator(directory, {"*.json", "*.skel"}, QDir::Files,
|
||||
recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags);
|
||||
while (iterator.hasNext()) addSkeleton(iterator.next());
|
||||
};
|
||||
if (info.isDir()) scanDirectory(path, true);
|
||||
else if (info.suffix().compare("json", Qt::CaseInsensitive) == 0
|
||||
|| info.suffix().compare("skel", Qt::CaseInsensitive) == 0) addSkeleton(path);
|
||||
else scanDirectory(info.absolutePath(), false);
|
||||
scheduleAvailabilityCheck();
|
||||
}
|
||||
|
||||
void MainWindow::addSkeleton(const QString& path) {
|
||||
const auto result = SpineDataVersionDetector{}.inspect(localPath(path));
|
||||
if (result.status != VersionDetectionStatus::Detected || skeletonPaths_.contains(path)) return;
|
||||
skeletonPaths_.append(path);
|
||||
resourceCombo_->addItem(QFileInfo(path).completeBaseName(), path);
|
||||
resourceCombo_->setVisible(true);
|
||||
resourceCombo_->setCurrentIndex(resourceCombo_->count() - 1);
|
||||
}
|
||||
|
||||
QString MainWindow::findAtlas(const QString& skeletonPath) const {
|
||||
const QFileInfo skeleton(skeletonPath);
|
||||
QDir directory(skeleton.absolutePath());
|
||||
const QString base = skeleton.completeBaseName();
|
||||
for (const auto& name : {base + ".atlas", base + ".atlas.txt"}) {
|
||||
const auto candidate = directory.filePath(name);
|
||||
if (QFileInfo::exists(candidate)) return candidate;
|
||||
}
|
||||
const auto atlases = directory.entryInfoList({"*.atlas", "*.atlas.txt"}, QDir::Files);
|
||||
if (atlases.size() == 1) return atlases.front().absoluteFilePath();
|
||||
for (const auto& atlas : atlases) {
|
||||
if (base.startsWith(atlas.completeBaseName())
|
||||
|| atlas.completeBaseName().startsWith(base)) return atlas.absoluteFilePath();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QString MainWindow::runtimeLibrary(const QString& versionLine) const {
|
||||
const QDir executableDirectory(QCoreApplication::applicationDirPath());
|
||||
#if defined(__APPLE__)
|
||||
return QDir::cleanPath(executableDirectory.filePath(
|
||||
"../PlugIns/runtimes/" + platformLibraryName(versionLine)));
|
||||
#else
|
||||
return executableDirectory.filePath("runtimes/" + platformLibraryName(versionLine));
|
||||
#endif
|
||||
}
|
||||
|
||||
void MainWindow::resourceChanged(int index) {
|
||||
if (index >= 0) {
|
||||
loadCurrentPreview();
|
||||
scheduleAvailabilityCheck();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::loadCurrentPreview() {
|
||||
previewTimer_->stop();
|
||||
previewWidget_->resetViewport();
|
||||
animationCombo_->clear();
|
||||
animationCombo_->setEnabled(false);
|
||||
convertButton_->setEnabled(false);
|
||||
const QString skeletonPath = resourceCombo_->currentData().toString();
|
||||
const QString atlasPath = findAtlas(skeletonPath);
|
||||
const auto detection = SpineDataVersionDetector{}.inspect(localPath(skeletonPath));
|
||||
if (!detection.version || atlasPath.isEmpty()) {
|
||||
previewWidget_->setMessage(tr("没有找到与骨骼数据配套的 .atlas 文件"));
|
||||
statusLabel_->setText(tr("请把完整导出资源一起拖入"));
|
||||
return;
|
||||
}
|
||||
currentVersion_ = QString::fromStdString(detection.version->toString());
|
||||
std::string error;
|
||||
if (!runtime_.load(localPath(runtimeLibrary(
|
||||
QString::fromStdString(detection.version->line()))), error)) {
|
||||
previewWidget_->setMessage(tr("暂时无法加载这套资源"));
|
||||
statusLabel_->setText(QString::fromStdString(error));
|
||||
return;
|
||||
}
|
||||
const auto metadata = runtime_.loadSkeleton(
|
||||
localPath(skeletonPath), localPath(atlasPath), 1.0F, error);
|
||||
if (!metadata) {
|
||||
previewWidget_->setMessage(tr("此版本的预览模块仍在接入中"));
|
||||
statusLabel_->setText(tr("资源已识别,可以稍后转换"));
|
||||
return;
|
||||
}
|
||||
for (const auto& animation : metadata->animations)
|
||||
animationCombo_->addItem(QString::fromStdString(animation.name), animation.duration);
|
||||
animationCombo_->setEnabled(animationCombo_->count() > 0);
|
||||
statusLabel_->setText(tr("已识别 %1 个动画").arg(metadata->animations.size()));
|
||||
if (animationCombo_->count() > 0) {
|
||||
animationChanged(animationCombo_->itemText(0));
|
||||
} else {
|
||||
auto frame = runtime_.updatePreview(0.0F, error);
|
||||
if (frame) previewWidget_->setFrame(std::move(*frame));
|
||||
else previewWidget_->setMessage(tr("没有可播放的动画"));
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::animationChanged(const QString& name) {
|
||||
if (name.isEmpty()) return;
|
||||
std::string error;
|
||||
if (!runtime_.setAnimation(name.toStdString(), true, error)) {
|
||||
statusLabel_->setText(QString::fromStdString(error));
|
||||
return;
|
||||
}
|
||||
advancePreview();
|
||||
previewTimer_->start();
|
||||
}
|
||||
|
||||
void MainWindow::advancePreview() {
|
||||
std::string error;
|
||||
auto frame = runtime_.updatePreview(0.016F, error);
|
||||
if (!frame) {
|
||||
previewTimer_->stop();
|
||||
previewWidget_->setMessage(tr("动画预览暂时不可用"));
|
||||
statusLabel_->setText(QString::fromStdString(error));
|
||||
return;
|
||||
}
|
||||
previewWidget_->setFrame(std::move(*frame));
|
||||
}
|
||||
|
||||
QString MainWindow::resolveEditor() {
|
||||
QSettings settings;
|
||||
SpineEditorLocator locator;
|
||||
const auto saved = settings.value("spineEditorPath").toString();
|
||||
if (!saved.isEmpty()) {
|
||||
if (const auto normalized = locator.normalize(localPath(saved)))
|
||||
return QString::fromStdString(normalized->string());
|
||||
}
|
||||
const auto found = locator.discover();
|
||||
if (!found.empty()) return QString::fromStdString(found.front().string());
|
||||
return {};
|
||||
}
|
||||
|
||||
void MainWindow::scheduleAvailabilityCheck() {
|
||||
if (availabilityCheckScheduled_) return;
|
||||
availabilityCheckScheduled_ = true;
|
||||
QTimer::singleShot(0, this, [this] {
|
||||
availabilityCheckScheduled_ = false;
|
||||
refreshAvailability();
|
||||
});
|
||||
}
|
||||
|
||||
void MainWindow::showAvailability(const QString& text, const QString& color) {
|
||||
availabilityLabel_->setText(QStringLiteral("● ") + text);
|
||||
availabilityLabel_->setStyleSheet(QStringLiteral(
|
||||
"QLabel { color: %1; background: #20242c; border: 1px solid #343a46; "
|
||||
"border-radius: 8px; padding: 7px 12px; font-weight: 600; }").arg(color));
|
||||
availabilityLabel_->setVisible(true);
|
||||
}
|
||||
|
||||
void MainWindow::refreshAvailability() {
|
||||
availableVersions_.clear();
|
||||
currentEditorVersion_.clear();
|
||||
editorPath_ = resolveEditor();
|
||||
SpineVersionAvailability availability;
|
||||
for (const auto& path : skeletonPaths_) {
|
||||
const auto detection = SpineDataVersionDetector{}.inspect(localPath(path));
|
||||
if (!detection.version) continue;
|
||||
const auto line = QString::fromStdString(detection.version->line());
|
||||
if (const auto installed = availability.newestForLine(detection.version->line()))
|
||||
availableVersions_.insert(line, QString::fromStdString(installed->toString()));
|
||||
}
|
||||
const auto current = SpineDataVersionDetector{}.inspect(
|
||||
localPath(resourceCombo_->currentData().toString()));
|
||||
if (!current.version) return;
|
||||
const auto atlasPath = findAtlas(resourceCombo_->currentData().toString());
|
||||
if (atlasPath.isEmpty()) {
|
||||
showAvailability(tr("缺少配套 Atlas"), QStringLiteral("#ef6b73"));
|
||||
convertButton_->setText(tr("资源不完整"));
|
||||
convertButton_->setEnabled(false);
|
||||
return;
|
||||
}
|
||||
const auto atlas = AtlasParser{}.parseFile(localPath(atlasPath));
|
||||
if (!atlas.document) {
|
||||
showAvailability(tr("Atlas 无法识别"), QStringLiteral("#ef6b73"));
|
||||
convertButton_->setText(tr("资源不完整"));
|
||||
convertButton_->setEnabled(false);
|
||||
return;
|
||||
}
|
||||
const QDir atlasDirectory = QFileInfo(atlasPath).dir();
|
||||
for (const auto& page : atlas.document->pages) {
|
||||
const auto pagePath = QString::fromStdString(page.imagePath.string());
|
||||
if (!QFileInfo::exists(atlasDirectory.filePath(pagePath))) {
|
||||
showAvailability(tr("缺少纹理页 %1").arg(QFileInfo(pagePath).fileName()),
|
||||
QStringLiteral("#ef6b73"));
|
||||
convertButton_->setText(tr("资源不完整"));
|
||||
convertButton_->setEnabled(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const auto line = QString::fromStdString(current.version->line());
|
||||
if (editorPath_.isEmpty()) {
|
||||
showAvailability(tr("未找到 Spine Editor"), QStringLiteral("#f2b84b"));
|
||||
convertButton_->setText(tr("指定 Spine 路径"));
|
||||
convertButton_->setEnabled(true);
|
||||
return;
|
||||
}
|
||||
currentEditorVersion_ = availableVersions_.value(line);
|
||||
if (currentEditorVersion_.isEmpty()) {
|
||||
showAvailability(tr("缺少 Spine %1").arg(line), QStringLiteral("#ef6b73"));
|
||||
convertButton_->setText(tr("指定 Spine %1 路径").arg(line));
|
||||
convertButton_->setEnabled(true);
|
||||
return;
|
||||
}
|
||||
showAvailability(tr("可转换 · Spine %1").arg(currentEditorVersion_),
|
||||
QStringLiteral("#55d68b"));
|
||||
convertButton_->setText(tr("一键转换为 .spine"));
|
||||
convertButton_->setEnabled(true);
|
||||
}
|
||||
|
||||
bool MainWindow::chooseEditorPath() {
|
||||
const auto selected = QFileDialog::getOpenFileName(this,
|
||||
tr("指定 Spine 程序"), {}, tr("所有文件 (*)"));
|
||||
if (selected.isEmpty()) return false;
|
||||
SpineEditorLocator locator;
|
||||
const auto normalized = locator.normalize(localPath(selected));
|
||||
if (!normalized) {
|
||||
showAvailability(tr("选择的路径不是 Spine Editor"), QStringLiteral("#ef6b73"));
|
||||
return false;
|
||||
}
|
||||
QSettings{}.setValue("spineEditorPath", selected);
|
||||
editorPath_ = QString::fromStdString(normalized->string());
|
||||
refreshAvailability();
|
||||
return !currentEditorVersion_.isEmpty();
|
||||
}
|
||||
|
||||
void MainWindow::convertCurrent() {
|
||||
if (editorPath_.isEmpty() || currentEditorVersion_.isEmpty())
|
||||
if (!chooseEditorPath()) return;
|
||||
const QString input = resourceCombo_->currentData().toString();
|
||||
const auto outputParent = QFileDialog::getExistingDirectory(this,
|
||||
tr("选择输出位置"), QFileInfo(input).absolutePath());
|
||||
if (outputParent.isEmpty()) return;
|
||||
const auto atlas = findAtlas(input);
|
||||
if (atlas.isEmpty()) {
|
||||
QMessageBox::critical(this, tr("转换失败"), tr("没有找到配套的 .atlas 文件。"));
|
||||
return;
|
||||
}
|
||||
ConversionRequest request{
|
||||
editorPath_, currentEditorVersion_, input, atlas, outputParent,
|
||||
QFileInfo(input).completeBaseName(), unpremultiplyCheck_->isChecked()};
|
||||
conversionJob_ = new ConversionJob(std::move(request), this);
|
||||
connect(conversionJob_, &ConversionJob::finished,
|
||||
this, &MainWindow::conversionFinished);
|
||||
setBusy(true);
|
||||
conversionJob_->start();
|
||||
}
|
||||
|
||||
void MainWindow::setBusy(bool busy) {
|
||||
convertButton_->setEnabled(!busy);
|
||||
convertButton_->setText(busy ? tr("正在转换…") : tr("一键转换为 .spine"));
|
||||
if (busy) showAvailability(tr("正在使用 Spine %1 转换").arg(currentEditorVersion_),
|
||||
QStringLiteral("#72a7ff"));
|
||||
}
|
||||
|
||||
void MainWindow::conversionFinished(bool success, const QString& outputDirectory,
|
||||
const QString& error) {
|
||||
conversionJob_->deleteLater();
|
||||
conversionJob_ = nullptr;
|
||||
setBusy(false);
|
||||
statusLabel_->setText(success ? tr("转换完成") : tr("转换失败"));
|
||||
statusLabel_->setToolTip(success ? outputDirectory : error);
|
||||
refreshAvailability();
|
||||
if (success) {
|
||||
QMessageBox::information(this, tr("转换完成"),
|
||||
tr("完整资源已生成:\n%1").arg(outputDirectory));
|
||||
} else {
|
||||
QMessageBox::critical(this, tr("转换失败"),
|
||||
error.isEmpty() ? tr("转换未完成,Spine Editor 未提供详细原因。") : error);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::dragEnterEvent(QDragEnterEvent* event) {
|
||||
if (event->mimeData()->hasUrls()) event->acceptProposedAction();
|
||||
}
|
||||
|
||||
void MainWindow::dropEvent(QDropEvent* event) {
|
||||
for (const auto& url : event->mimeData()->urls())
|
||||
if (url.isLocalFile()) addInput(url.toLocalFile());
|
||||
event->acceptProposedAction();
|
||||
}
|
||||
|
||||
} // namespace dts
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
#include "preview/RuntimePlugin.hpp"
|
||||
|
||||
#include <QMainWindow>
|
||||
#include <QHash>
|
||||
#include <QStringList>
|
||||
|
||||
class QComboBox;
|
||||
class QCheckBox;
|
||||
class QLabel;
|
||||
class QPushButton;
|
||||
class QTimer;
|
||||
|
||||
namespace dts {
|
||||
|
||||
class SpinePreviewWidget;
|
||||
class ConversionJob;
|
||||
|
||||
class MainWindow final : public QMainWindow {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit MainWindow(QWidget* parent = nullptr);
|
||||
void openResourcePath(const QString& path);
|
||||
|
||||
protected:
|
||||
void dragEnterEvent(QDragEnterEvent* event) override;
|
||||
void dropEvent(QDropEvent* event) override;
|
||||
|
||||
private slots:
|
||||
void chooseResources();
|
||||
void resourceChanged(int index);
|
||||
void convertCurrent();
|
||||
void conversionFinished(bool success, const QString& outputDirectory,
|
||||
const QString& error);
|
||||
void animationChanged(const QString& name);
|
||||
void advancePreview();
|
||||
|
||||
private:
|
||||
void setupUi();
|
||||
void addInput(const QString& path);
|
||||
void addSkeleton(const QString& path);
|
||||
void loadCurrentPreview();
|
||||
QString findAtlas(const QString& skeletonPath) const;
|
||||
QString runtimeLibrary(const QString& versionLine) const;
|
||||
QString resolveEditor();
|
||||
void scheduleAvailabilityCheck();
|
||||
void refreshAvailability();
|
||||
bool chooseEditorPath();
|
||||
void showAvailability(const QString& text, const QString& color);
|
||||
void showEmptyState();
|
||||
void setBusy(bool busy);
|
||||
|
||||
QComboBox* resourceCombo_ = nullptr;
|
||||
QComboBox* animationCombo_ = nullptr;
|
||||
QCheckBox* unpremultiplyCheck_ = nullptr;
|
||||
SpinePreviewWidget* previewWidget_ = nullptr;
|
||||
QLabel* statusLabel_ = nullptr;
|
||||
QLabel* availabilityLabel_ = nullptr;
|
||||
QPushButton* convertButton_ = nullptr;
|
||||
QStringList skeletonPaths_;
|
||||
QString currentVersion_;
|
||||
QString currentEditorVersion_;
|
||||
QString editorPath_;
|
||||
QHash<QString, QString> availableVersions_;
|
||||
bool availabilityCheckScheduled_ = false;
|
||||
RuntimePlugin runtime_;
|
||||
ConversionJob* conversionJob_ = nullptr;
|
||||
QTimer* previewTimer_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace dts
|
||||
@@ -0,0 +1,195 @@
|
||||
#include "ui/SpinePreviewWidget.hpp"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QColor>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QPolygonF>
|
||||
#include <QTransform>
|
||||
#include <QWheelEvent>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
namespace dts {
|
||||
namespace {
|
||||
|
||||
QString localString(const std::filesystem::path& path) {
|
||||
#if defined(_WIN32)
|
||||
return QString::fromStdWString(path.wstring());
|
||||
#else
|
||||
const auto text = path.u8string();
|
||||
return QString::fromUtf8(reinterpret_cast<const char*>(text.data()),
|
||||
static_cast<qsizetype>(text.size()));
|
||||
#endif
|
||||
}
|
||||
|
||||
QPainter::CompositionMode compositionMode(std::uint32_t blendMode) {
|
||||
switch (blendMode) {
|
||||
case 1: return QPainter::CompositionMode_Plus;
|
||||
case 2: return QPainter::CompositionMode_Multiply;
|
||||
case 3: return QPainter::CompositionMode_Screen;
|
||||
default: return QPainter::CompositionMode_SourceOver;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SpinePreviewWidget::SpinePreviewWidget(QWidget* parent) : QWidget(parent) {
|
||||
setMinimumSize(640, 440);
|
||||
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||
setAutoFillBackground(false);
|
||||
setMouseTracking(true);
|
||||
setToolTip(tr("滚轮缩放 · 中键重置 · 右键拖动 · 右键单击居中"));
|
||||
}
|
||||
|
||||
void SpinePreviewWidget::setFrame(std::vector<PreviewDrawCommand> commands) {
|
||||
if (!viewBounds_ && !commands.empty()) {
|
||||
float left = std::numeric_limits<float>::max();
|
||||
float right = std::numeric_limits<float>::lowest();
|
||||
float bottom = std::numeric_limits<float>::max();
|
||||
float top = std::numeric_limits<float>::lowest();
|
||||
for (const auto& command : commands) for (const auto& vertex : command.vertices) {
|
||||
left = std::min(left, vertex.x);
|
||||
right = std::max(right, vertex.x);
|
||||
bottom = std::min(bottom, vertex.y);
|
||||
top = std::max(top, vertex.y);
|
||||
}
|
||||
if (right > left && top > bottom)
|
||||
viewBounds_ = QRectF(left, bottom, right - left, top - bottom);
|
||||
}
|
||||
commands_ = std::move(commands);
|
||||
message_.clear();
|
||||
update();
|
||||
}
|
||||
|
||||
void SpinePreviewWidget::resetViewport() {
|
||||
viewBounds_.reset();
|
||||
zoom_ = 1.0F;
|
||||
pan_ = {};
|
||||
}
|
||||
|
||||
void SpinePreviewWidget::setMessage(const QString& message) {
|
||||
commands_.clear();
|
||||
message_ = message;
|
||||
update();
|
||||
}
|
||||
|
||||
const QImage& SpinePreviewWidget::texture(const std::filesystem::path& path) {
|
||||
const auto key = localString(path);
|
||||
auto found = textures_.find(key);
|
||||
if (found == textures_.end()) found = textures_.insert(key, QImage(key));
|
||||
return found.value();
|
||||
}
|
||||
|
||||
void SpinePreviewWidget::paintEvent(QPaintEvent*) {
|
||||
QPainter painter(this);
|
||||
painter.setRenderHint(QPainter::SmoothPixmapTransform);
|
||||
painter.fillRect(rect(), QColor("#101217"));
|
||||
if (commands_.empty()) {
|
||||
painter.setPen(QColor("#a7adb8"));
|
||||
painter.drawText(rect(), Qt::AlignCenter | Qt::TextWordWrap, message_);
|
||||
return;
|
||||
}
|
||||
|
||||
const QRectF bounds = viewBounds_.value_or(QRectF(0, 0, 1, 1));
|
||||
const float contentWidth = std::max(1.0, bounds.width());
|
||||
const float contentHeight = std::max(1.0, bounds.height());
|
||||
const float baseScale = 0.94F * std::min(width() / contentWidth, height() / contentHeight);
|
||||
const float scale = baseScale * zoom_;
|
||||
const QPointF center = bounds.center();
|
||||
const auto toScreen = [this, center, scale](const PreviewVertex& vertex) {
|
||||
return QPointF(width() * 0.5 + pan_.x() + (vertex.x - center.x()) * scale,
|
||||
height() * 0.5 + pan_.y() + (vertex.y - center.y()) * scale);
|
||||
};
|
||||
|
||||
for (const auto& command : commands_) {
|
||||
const auto& image = texture(command.texturePath);
|
||||
if (image.isNull()) continue;
|
||||
painter.setCompositionMode(compositionMode(command.blendMode));
|
||||
for (std::size_t index = 0; index + 2 < command.indices.size(); index += 3) {
|
||||
const auto i0 = command.indices[index];
|
||||
const auto i1 = command.indices[index + 1];
|
||||
const auto i2 = command.indices[index + 2];
|
||||
if (i0 >= command.vertices.size() || i1 >= command.vertices.size()
|
||||
|| i2 >= command.vertices.size()) continue;
|
||||
const auto& v0 = command.vertices[i0];
|
||||
const auto& v1 = command.vertices[i1];
|
||||
const auto& v2 = command.vertices[i2];
|
||||
const QPointF s0(v0.u * image.width(), v0.v * image.height());
|
||||
const QPointF s1(v1.u * image.width(), v1.v * image.height());
|
||||
const QPointF s2(v2.u * image.width(), v2.v * image.height());
|
||||
const QPointF d0 = toScreen(v0);
|
||||
const QPointF d1 = toScreen(v1);
|
||||
const QPointF d2 = toScreen(v2);
|
||||
QPolygonF source{s0, s1, s2, s1 + s2 - s0};
|
||||
QPolygonF destination{d0, d1, d2, d1 + d2 - d0};
|
||||
QTransform transform;
|
||||
if (!QTransform::quadToQuad(source, destination, transform)) continue;
|
||||
QPainterPath clip;
|
||||
clip.addPolygon(QPolygonF{d0, d1, d2});
|
||||
painter.save();
|
||||
painter.setClipPath(clip);
|
||||
painter.setOpacity(QColor::fromRgba(v0.color).alphaF());
|
||||
painter.setTransform(transform);
|
||||
painter.drawImage(QPointF(0, 0), image);
|
||||
painter.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SpinePreviewWidget::wheelEvent(QWheelEvent* event) {
|
||||
if (commands_.empty() || event->angleDelta().y() == 0) return;
|
||||
const float previousZoom = zoom_;
|
||||
const float factor = event->angleDelta().y() > 0 ? 1.12F : 1.0F / 1.12F;
|
||||
zoom_ = std::clamp(previousZoom * factor, 0.15F, 8.0F);
|
||||
const QPointF canvasCenter(width() * 0.5, height() * 0.5);
|
||||
const QPointF cursorOffset = event->position() - canvasCenter;
|
||||
pan_ = cursorOffset - (cursorOffset - pan_) * (zoom_ / previousZoom);
|
||||
update();
|
||||
event->accept();
|
||||
}
|
||||
|
||||
void SpinePreviewWidget::mousePressEvent(QMouseEvent* event) {
|
||||
if (event->button() == Qt::MiddleButton) {
|
||||
zoom_ = 1.0F;
|
||||
pan_ = {};
|
||||
update();
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
if (event->button() == Qt::RightButton) {
|
||||
rightPressPosition_ = event->position();
|
||||
lastMousePosition_ = event->position();
|
||||
rightDragging_ = false;
|
||||
setCursor(Qt::ClosedHandCursor);
|
||||
event->accept();
|
||||
}
|
||||
}
|
||||
|
||||
void SpinePreviewWidget::mouseMoveEvent(QMouseEvent* event) {
|
||||
if (!(event->buttons() & Qt::RightButton)) return;
|
||||
if (!rightDragging_ && (event->position() - rightPressPosition_).manhattanLength()
|
||||
>= QApplication::startDragDistance()) rightDragging_ = true;
|
||||
if (rightDragging_) {
|
||||
pan_ += event->position() - lastMousePosition_;
|
||||
update();
|
||||
}
|
||||
lastMousePosition_ = event->position();
|
||||
event->accept();
|
||||
}
|
||||
|
||||
void SpinePreviewWidget::mouseReleaseEvent(QMouseEvent* event) {
|
||||
if (event->button() != Qt::RightButton) return;
|
||||
unsetCursor();
|
||||
if (!rightDragging_) {
|
||||
pan_ = {};
|
||||
update();
|
||||
}
|
||||
rightDragging_ = false;
|
||||
event->accept();
|
||||
}
|
||||
|
||||
} // namespace dts
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include "preview/RuntimePlugin.hpp"
|
||||
|
||||
#include <QHash>
|
||||
#include <QImage>
|
||||
#include <QPointF>
|
||||
#include <QRectF>
|
||||
#include <QWidget>
|
||||
|
||||
#include <optional>
|
||||
|
||||
namespace dts {
|
||||
|
||||
class SpinePreviewWidget final : public QWidget {
|
||||
public:
|
||||
explicit SpinePreviewWidget(QWidget* parent = nullptr);
|
||||
|
||||
void setFrame(std::vector<PreviewDrawCommand> commands);
|
||||
void setMessage(const QString& message);
|
||||
void resetViewport();
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent* event) override;
|
||||
void wheelEvent(QWheelEvent* event) override;
|
||||
void mousePressEvent(QMouseEvent* event) override;
|
||||
void mouseMoveEvent(QMouseEvent* event) override;
|
||||
void mouseReleaseEvent(QMouseEvent* event) override;
|
||||
|
||||
private:
|
||||
const QImage& texture(const std::filesystem::path& path);
|
||||
|
||||
std::vector<PreviewDrawCommand> commands_;
|
||||
QHash<QString, QImage> textures_;
|
||||
QString message_;
|
||||
std::optional<QRectF> viewBounds_;
|
||||
float zoom_ = 1.0F;
|
||||
QPointF pan_;
|
||||
QPointF rightPressPosition_;
|
||||
QPointF lastMousePosition_;
|
||||
bool rightDragging_ = false;
|
||||
};
|
||||
|
||||
} // namespace dts
|
||||
Reference in New Issue
Block a user