diff --git a/.github/workflows/actions/runCPPTests/runAllTests/action.yml b/.github/workflows/actions/runCPPTests/runAllTests/action.yml index 9ada5690c6..7eaaab59ce 100644 --- a/.github/workflows/actions/runCPPTests/runAllTests/action.yml +++ b/.github/workflows/actions/runCPPTests/runAllTests/action.yml @@ -117,6 +117,13 @@ runs: testCommand: ${{ inputs.builddir }}/tests/Sketcher_tests_run --gtest_output=json:${{ inputs.reportdir }}spreadsheet_gtest_results.json testLogFile: ${{ inputs.reportdir }}spreadsheet_gtest_test_log.txt testName: Spreadsheet + - name: C++ Start tests + id: start + uses: ./.github/workflows/actions/runCPPTests/runSingleTest + with: + testCommand: ${{ inputs.builddir }}/tests/Start_tests_run --gtest_output=json:${{ inputs.reportdir }}start_gtest_results.json + testLogFile: ${{ inputs.reportdir }}start_gtest_test_log.txt + testName: Start - name: Compose summary report based on test results if: always() shell: bash -l {0} diff --git a/src/Mod/Start/App/CMakeLists.txt b/src/Mod/Start/App/CMakeLists.txt index 3aa8f6585d..60c60a7694 100644 --- a/src/Mod/Start/App/CMakeLists.txt +++ b/src/Mod/Start/App/CMakeLists.txt @@ -25,7 +25,7 @@ set(Start_LIBS FreeCADApp ) -SET(Start_SRCS +set(Start_SRCS AppStart.cpp DisplayedFilesModel.cpp DisplayedFilesModel.h @@ -33,10 +33,14 @@ SET(Start_SRCS ExamplesModel.h CustomFolderModel.cpp CustomFolderModel.h + FileUtilities.cpp + FileUtilities.h PreCompiled.cpp PreCompiled.h RecentFilesModel.cpp - RecentFilesModel.h) + RecentFilesModel.h + ThumbnailSource.cpp + ThumbnailSource.h) add_library(Start SHARED ${Start_SRCS}) target_link_libraries(Start ${Start_LIBS}) @@ -44,7 +48,7 @@ if (FREECAD_WARN_ERROR) target_compile_warn_error(Start) endif() -SET_BIN_DIR(Start Start /Mod/Start) -SET_PYTHON_PREFIX_SUFFIX(Start) +set_bin_dir(Start Start /Mod/Start) +set_python_prefix_suffix(Start) -INSTALL(TARGETS Start DESTINATION ${CMAKE_INSTALL_LIBDIR}) +install(TARGETS Start DESTINATION ${CMAKE_INSTALL_LIBDIR}) diff --git a/src/Mod/Start/App/DisplayedFilesModel.cpp b/src/Mod/Start/App/DisplayedFilesModel.cpp index 33f0f77557..06d631df43 100644 --- a/src/Mod/Start/App/DisplayedFilesModel.cpp +++ b/src/Mod/Start/App/DisplayedFilesModel.cpp @@ -25,55 +25,28 @@ #ifndef _PreComp_ #include #include -#include -#include -#include -#include #include #include -#include +#include +#include #include #endif #include "DisplayedFilesModel.h" + + +#include "FileUtilities.h" +#include "ThumbnailSource.h" #include #include #include -#include #include +#include + using namespace Start; -namespace -{ - -std::string humanReadableSize(unsigned int bytes) -{ - static const std::vector siPrefix { - "b", - "kb", - "Mb", - "Gb", - "Tb", - "Pb", - "Eb" // I think it's safe to stop here (for the time being)... - }; - size_t base = 0; - double inUnits = bytes; - constexpr double siFactor {1000.0}; - while (inUnits > siFactor && base < siPrefix.size() - 1) { - ++base; - inUnits /= siFactor; - } - if (base == 0) { - // Don't include a decimal point for bytes - return fmt::format("{:.0f} {}", inUnits, siPrefix[base]); - } - // For all others, include one digit after the decimal place - return fmt::format("{:.1f} {}", inUnits, siPrefix[base]); -} - FileStats fileInfoFromFreeCADFile(const std::string& path) { App::ProjectFile proj(path); @@ -90,146 +63,46 @@ FileStats fileInfoFromFreeCADFile(const std::string& path) return result; } -std::string getThumbnailsImage() -{ - return "thumbnails/Thumbnail.png"; -} - -QString getThumbnailsName() -{ -#if defined(Q_OS_LINUX) - return QStringLiteral("thumbnails/normal"); -#else - return QStringLiteral("FreeCADStartThumbnails"); -#endif -} - -QDir getThumnailsParentDir() -{ - return {QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation)}; -} - -QString getThumbnailsDir() -{ - QDir dir = getThumnailsParentDir(); - return dir.absoluteFilePath(getThumbnailsName()); -} - -void createThumbnailsDir() -{ - QString name = getThumbnailsName(); - QDir dir(getThumnailsParentDir()); - if (!dir.exists(name)) { - dir.mkpath(name); - } -} - -QString getMD5Hash(const std::string& path) -{ - // Use MD5 hash as specified here: - // https://specifications.freedesktop.org/thumbnail-spec/0.8.0/thumbsave.html - QUrl url(QString::fromStdString(path)); - url.setScheme(QStringLiteral("file")); - QCryptographicHash hash(QCryptographicHash::Md5); - hash.addData(url.toEncoded()); - QByteArray ba = hash.result().toHex(); - return QString::fromLatin1(ba); -} - -QString getUniquePNG(const std::string& path) -{ - QDir dir = getThumbnailsDir(); - QString md5 = getMD5Hash(path) + QLatin1String(".png"); - return dir.absoluteFilePath(md5); -} - -bool useCachedPNG(const std::string& image, const std::string& project) -{ - Base::FileInfo f1(image); - Base::FileInfo f2(project); - if (!f1.exists()) { - return false; - } - if (!f2.exists()) { - return false; - } - - return f1.lastModified() > f2.lastModified(); -} - /// Load the thumbnail image data (if any) that is stored in an FCStd file. /// \returns The image bytes, or an empty QByteArray (if no thumbnail was stored) -QByteArray loadFCStdThumbnail(const std::string& pathToFCStdFile) +QByteArray loadFCStdThumbnail(const QString& pathToFCStdFile) { - App::ProjectFile proj(pathToFCStdFile); - if (proj.loadDocument()) { + if (App::ProjectFile proj(pathToFCStdFile.toStdString()); proj.loadDocument()) { try { - std::string thumbnailFile = getUniquePNG(pathToFCStdFile).toStdString(); - if (!useCachedPNG(thumbnailFile, pathToFCStdFile)) { - static std::string thumb = getThumbnailsImage(); - if (proj.containsFile(thumb)) { + const QString pathToCachedThumbnail = getPathToCachedThumbnail(pathToFCStdFile); + if (!useCachedThumbnail(pathToCachedThumbnail, pathToFCStdFile)) { + static const QString pathToThumbnail = defaultThumbnailPath; + if (proj.containsFile(pathToThumbnail.toStdString())) { createThumbnailsDir(); - Base::FileInfo fi(thumbnailFile); - Base::ofstream str(fi, std::ios::out | std::ios::binary); - proj.readInputFileDirect(thumb, str); - str.close(); + const Base::FileInfo fi(pathToCachedThumbnail.toStdString()); + Base::ofstream stream(fi, std::ios::out | std::ios::binary); + proj.readInputFileDirect(pathToThumbnail.toStdString(), stream); + stream.close(); } } - auto inputFile = QFile(QString::fromStdString(thumbnailFile)); - if (inputFile.exists()) { + if (auto inputFile = QFile(pathToCachedThumbnail); inputFile.exists()) { inputFile.open(QIODevice::OpenModeFlag::ReadOnly); return inputFile.readAll(); } } catch (...) { + Base::Console().Log("Failed to load thumbnail for %s\n", pathToFCStdFile.toStdString()); } } return {}; } -/// Attempt to generate a thumbnail image from a file using f3d or load from cache -/// \returns The image bytes, or an empty QByteArray (if thumbnail generation fails) -QByteArray getF3dThumbnail(const std::string& pathToFile) -{ - QString thumbnailPath = getUniquePNG(pathToFile); - if (!useCachedPNG(thumbnailPath.toStdString(), pathToFile)) { - ParameterGrp::handle hGrp = App::GetApplication().GetParameterGroupByPath( - "User parameter:BaseApp/Preferences/Mod/Start"); - auto f3d = QString::fromUtf8(hGrp->GetASCII("f3d", "f3d").c_str()); - const int resolution = 128; - QStringList args; - args << QLatin1String("--config=thumbnail") << QLatin1String("--load-plugins=occt") - << QLatin1String("--verbose=quiet") << QLatin1String("--output=") + thumbnailPath - << QLatin1String("--resolution=") + QString::number(resolution) + QLatin1String(",") - + QString::number(resolution) - << QString::fromStdString(pathToFile); - - QProcess process; - process.start(f3d, args); - process.waitForFinished(); - if (process.exitCode() != 0) { - return {}; - } - } - - QFile thumbnailFile(thumbnailPath); - if (thumbnailFile.exists()) { - thumbnailFile.open(QIODevice::OpenModeFlag::ReadOnly); - return thumbnailFile.readAll(); - } - return {}; -} - FileStats getFileInfo(const std::string& path) { FileStats result; - Base::FileInfo file(path); + const Base::FileInfo file(path); if (file.hasExtension("FCStd")) { result = fileInfoFromFreeCADFile(path); } else { - file.lastModified(); + result.insert( + std::make_pair(DisplayedFilesModelRoles::modifiedTime, getLastModifiedAsString(file))); } result.insert(std::make_pair(DisplayedFilesModelRoles::path, path)); result.insert(std::make_pair(DisplayedFilesModelRoles::size, humanReadableSize(file.size()))); @@ -241,16 +114,13 @@ bool freecadCanOpen(const QString& extension) { std::string ext = extension.toStdString(); auto importTypes = App::GetApplication().getImportTypes(); - return std::find_if(importTypes.begin(), - importTypes.end(), - [&ext](const auto& item) { - return boost::iequals(item, ext); - }) + return std::ranges::find_if(importTypes, + [&ext](const auto& item) { + return boost::iequals(item, ext); + }) != importTypes.end(); } -} // namespace - DisplayedFilesModel::DisplayedFilesModel(QObject* parent) : QAbstractListModel(parent) {} @@ -262,15 +132,14 @@ int DisplayedFilesModel::rowCount(const QModelIndex& parent) const return static_cast(_fileInfoCache.size()); } -QVariant DisplayedFilesModel::data(const QModelIndex& index, int roleAsInt) const +QVariant DisplayedFilesModel::data(const QModelIndex& index, int role) const { - int row = index.row(); + const int row = index.row(); if (row < 0 || row >= static_cast(_fileInfoCache.size())) { return {}; } - auto mapEntry = _fileInfoCache.at(row); - auto role = static_cast(roleAsInt); - switch (role) { + const auto mapEntry = _fileInfoCache.at(row); + switch (const auto roleAsType = static_cast(role)) { case DisplayedFilesModelRoles::author: // NOLINT(bugprone-branch-clone) [[fallthrough]]; case DisplayedFilesModelRoles::baseName: @@ -288,15 +157,14 @@ QVariant DisplayedFilesModel::data(const QModelIndex& index, int roleAsInt) cons case DisplayedFilesModelRoles::path: [[fallthrough]]; case DisplayedFilesModelRoles::size: - if (mapEntry.find(role) != mapEntry.end()) { - return QString::fromStdString(mapEntry.at(role)); - } - else { - return {}; + if (mapEntry.contains(roleAsType)) { + return QString::fromStdString(mapEntry.at(roleAsType)); } + break; case DisplayedFilesModelRoles::image: { - auto path = QString::fromStdString(mapEntry.at(DisplayedFilesModelRoles::path)); - if (_imageCache.contains(path)) { + if (const auto path = + QString::fromStdString(mapEntry.at(DisplayedFilesModelRoles::path)); + _imageCache.contains(path)) { return _imageCache[path]; } break; @@ -304,16 +172,19 @@ QVariant DisplayedFilesModel::data(const QModelIndex& index, int roleAsInt) cons default: break; } - switch (roleAsInt) { + switch (role) { case Qt::ItemDataRole::ToolTipRole: return QString::fromStdString(mapEntry.at(DisplayedFilesModelRoles::path)); + default: + // No other role gets handled + break; } return {}; } void DisplayedFilesModel::addFile(const QString& filePath) { - QFileInfo qfi(filePath); + const QFileInfo qfi(filePath); if (!qfi.isReadable()) { return; } @@ -323,17 +194,28 @@ void DisplayedFilesModel::addFile(const QString& filePath) } _fileInfoCache.emplace_back(getFileInfo(filePath.toStdString())); - if (qfi.suffix().toLower() == QLatin1String("fcstd")) { - auto thumbnail = loadFCStdThumbnail(filePath.toStdString()); - if (!thumbnail.isEmpty()) { + const auto lowercaseExtension = qfi.suffix().toLower(); + const QStringList ignoredExtensions {QLatin1String("fcmacro"), + QLatin1String("py"), + QLatin1String("pyi"), + QLatin1String("csv"), + QLatin1String("txt")}; + if (lowercaseExtension == QLatin1String("fcstd")) { + if (const auto thumbnail = loadFCStdThumbnail(filePath); !thumbnail.isEmpty()) { _imageCache.insert(filePath, thumbnail); } } + else if (ignoredExtensions.contains(lowercaseExtension)) { + // Don't try to generate a thumbnail for things like this: FreeCAD can read them, but + // there's not much point in showing anything besides a generic icon + } else { - auto thumbnail = getF3dThumbnail(filePath.toStdString()); - if (!thumbnail.isEmpty()) { - _imageCache.insert(filePath, thumbnail); - } + const auto runner = new ThumbnailSource(filePath); + connect(runner->signals(), + &ThumbnailSourceSignals::thumbnailAvailable, + this, + &DisplayedFilesModel::processNewThumbnail); + QThreadPool::globalInstance()->start(runner); } } @@ -345,16 +227,37 @@ void DisplayedFilesModel::clear() QHash DisplayedFilesModel::roleNames() const { static QHash nameMap { - std::make_pair(int(DisplayedFilesModelRoles::author), "author"), - std::make_pair(int(DisplayedFilesModelRoles::baseName), "baseName"), - std::make_pair(int(DisplayedFilesModelRoles::company), "company"), - std::make_pair(int(DisplayedFilesModelRoles::creationTime), "creationTime"), - std::make_pair(int(DisplayedFilesModelRoles::description), "description"), - std::make_pair(int(DisplayedFilesModelRoles::image), "image"), - std::make_pair(int(DisplayedFilesModelRoles::license), "license"), - std::make_pair(int(DisplayedFilesModelRoles::modifiedTime), "modifiedTime"), - std::make_pair(int(DisplayedFilesModelRoles::path), "path"), - std::make_pair(int(DisplayedFilesModelRoles::size), "size"), + std::make_pair(static_cast(DisplayedFilesModelRoles::author), "author"), + std::make_pair(static_cast(DisplayedFilesModelRoles::baseName), "baseName"), + std::make_pair(static_cast(DisplayedFilesModelRoles::company), "company"), + std::make_pair(static_cast(DisplayedFilesModelRoles::creationTime), "creationTime"), + std::make_pair(static_cast(DisplayedFilesModelRoles::description), "description"), + std::make_pair(static_cast(DisplayedFilesModelRoles::image), "image"), + std::make_pair(static_cast(DisplayedFilesModelRoles::license), "license"), + std::make_pair(static_cast(DisplayedFilesModelRoles::modifiedTime), "modifiedTime"), + std::make_pair(static_cast(DisplayedFilesModelRoles::path), "path"), + std::make_pair(static_cast(DisplayedFilesModelRoles::size), "size"), }; return nameMap; } + +void DisplayedFilesModel::processNewThumbnail(const QString& file, const QByteArray& thumbnail) +{ + if (!thumbnail.isEmpty()) { + _imageCache.insert(file, thumbnail); + + // Figure out the index of this file... + auto it = std::ranges::find_if(_fileInfoCache, [file](const FileStats& row) { + auto pathIt = row.find(DisplayedFilesModelRoles::path); + return pathIt != row.end() && pathIt->second == file.toStdString(); + }); + if (it != _fileInfoCache.end()) { + std::size_t index = std::distance(_fileInfoCache.begin(), it); + QModelIndex qmi = createIndex(index, 0); + Q_EMIT(dataChanged(qmi, qmi, {static_cast(DisplayedFilesModelRoles::image)})); + } + else { + Base::Console().Log("Unrecognized path %s\n", file.toStdString()); + } + } +} diff --git a/src/Mod/Start/App/DisplayedFilesModel.h b/src/Mod/Start/App/DisplayedFilesModel.h index fd91af0663..e9bca126f9 100644 --- a/src/Mod/Start/App/DisplayedFilesModel.h +++ b/src/Mod/Start/App/DisplayedFilesModel.h @@ -21,8 +21,8 @@ * * ***************************************************************************/ -#ifndef FREECAD_START_DISPLAYEDFILESMODEL_H -#define FREECAD_START_DISPLAYEDFILESMODEL_H +#ifndef FREECAD_START_DISPLAYED_FILES_MODEL_H +#define FREECAD_START_DISPLAYED_FILES_MODEL_H #include #include @@ -69,10 +69,8 @@ protected: /// DisplayedFilesModelRoles enumeration QHash roleNames() const override; - /// Destroy and recreate the cache of info about the files. Should be connected to a signal - /// indicating when some piece of information about the files has changed. Does NOT generate - /// a new list of files, only re-caches the existing ones. - void reCacheFileInfo(); + /// Process a new thumbnail produces by some sort of worker thread + void processNewThumbnail(const QString& file, const QByteArray& thumbnail); private: std::vector _fileInfoCache; @@ -81,4 +79,4 @@ private: } // namespace Start -#endif // FREECAD_START_DISPLAYEDFILESMODEL_H +#endif // FREECAD_START_DISPLAYED_FILES_MODEL_H diff --git a/src/Mod/Start/App/FileUtilities.cpp b/src/Mod/Start/App/FileUtilities.cpp new file mode 100644 index 0000000000..5e4832d6d3 --- /dev/null +++ b/src/Mod/Start/App/FileUtilities.cpp @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 The FreeCAD Project Association AISBL * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD is distributed in the hope that it will be useful, but * + * WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#include "PreCompiled.h" +#ifndef _PreComp_ +#include +#include +#include +#include +#include +#include +#include +#endif + +#include "FileUtilities.h" +#include + + +void Start::createThumbnailsDir() +{ + if (!thumbnailsParentDir.exists(defaultThumbnailName)) { + thumbnailsParentDir.mkpath(defaultThumbnailName); + } +} + +QString Start::getMD5Hash(const QString& path) +{ + // Use MD5 hash as specified here: + // https://specifications.freedesktop.org/thumbnail-spec/0.8.0/thumbsave.html + QUrl url(path); + url.setScheme(QStringLiteral("file")); + QCryptographicHash hash(QCryptographicHash::Md5); + hash.addData(url.toEncoded()); + const QByteArray ba = hash.result().toHex(); + return QString::fromLatin1(ba); +} + +QString Start::getPathToCachedThumbnail(const QString& path) +{ + const QString md5 = getMD5Hash(path) + QLatin1String(".png"); + return thumbnailsDir.absoluteFilePath(md5); +} + +bool Start::useCachedThumbnail(const QString& image, const QString& project) +{ + const QFileInfo f1(image); + const QFileInfo f2(project); + if (!f1.exists()) { + return false; + } + if (!f2.exists()) { + return false; + } + + return f1.lastModified() > f2.lastModified(); +} + +std::string Start::humanReadableSize(uint64_t bytes) +{ + if (bytes == 0) { + return "0 B"; + } + + // uint64_t can't express numbers higher than the EB range (< 20 EB) + constexpr std::array units {"B", "kB", "MB", "GB", "TB", "PB", "EB"}; + constexpr double unitFactor = 1000.0; + + const double logBaseFactor = std::log10(unitFactor); // change to constexpr on c++26 + const auto unitIndex = static_cast(std::log10(bytes) / logBaseFactor); + + // unitIndex can't be out of range because uint64_t can't express numbers higher than the EB + // range + const auto unit = units.at(unitIndex); + + const double scaledValue = static_cast(bytes) / std::pow(unitFactor, unitIndex); + + const bool isByteUnit = unitIndex == 0; + const size_t precision = isByteUnit ? 0 : 1; + return fmt::format("{:.{}f} {}", scaledValue, precision, unit); +} + +std::string Start::getLastModifiedAsString(const Base::FileInfo& file) +{ + Base::TimeInfo lastModified = file.lastModified(); + return QDateTime::fromSecsSinceEpoch(lastModified.getTime_t()) + .toTimeZone(QTimeZone::utc()) + .toString(Qt::ISODate) + .toStdString(); +} diff --git a/src/Mod/Start/App/FileUtilities.h b/src/Mod/Start/App/FileUtilities.h new file mode 100644 index 0000000000..c919918064 --- /dev/null +++ b/src/Mod/Start/App/FileUtilities.h @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 The FreeCAD Project Association AISBL * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD is distributed in the hope that it will be useful, but * + * WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#ifndef FREECAD_FILEUTILITIES_H +#define FREECAD_FILEUTILITIES_H + +#include "Base/FileInfo.h" +#include "Mod/Start/StartGlobal.h" + +#include +#include +#include + +class QString; + +namespace Start +{ + +const QLatin1String defaultThumbnailPath("thumbnails/Thumbnail.png"); + +const QLatin1String defaultThumbnailName +#if defined(Q_OS_LINUX) + ("thumbnails/normal"); +#else + ("FreeCADStartThumbnails"); +#endif + +const QDir thumbnailsParentDir { + QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation)}; + +const QDir thumbnailsDir {thumbnailsParentDir.absoluteFilePath(defaultThumbnailName)}; + +StartExport void createThumbnailsDir(); + +StartExport QString getMD5Hash(const QString& path); + +StartExport QString getPathToCachedThumbnail(const QString& path); + +StartExport bool useCachedThumbnail(const QString& image, const QString& project); + +StartExport std::string humanReadableSize(std::uint64_t bytes); + +StartExport std::string getLastModifiedAsString(const Base::FileInfo& file); + +} // namespace Start + +#endif // FREECAD_FILEUTILITIES_H diff --git a/src/Mod/Start/App/PreCompiled.h b/src/Mod/Start/App/PreCompiled.h index 166e8268e1..4e742c98a5 100644 --- a/src/Mod/Start/App/PreCompiled.h +++ b/src/Mod/Start/App/PreCompiled.h @@ -45,6 +45,9 @@ // boost #include +// fmt +#include + // Qt (should never include GUI files, only QtCore) #include #include @@ -52,7 +55,15 @@ #include #include #include +#include +#include +#include +#include #include +#include +#include +#include +#include #include #endif // _PreComp_ diff --git a/src/Mod/Start/App/ThumbnailSource.cpp b/src/Mod/Start/App/ThumbnailSource.cpp new file mode 100644 index 0000000000..9a27bc1eac --- /dev/null +++ b/src/Mod/Start/App/ThumbnailSource.cpp @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 The FreeCAD Project Association AISBL * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD is distributed in the hope that it will be useful, but * + * WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#include "PreCompiled.h" +#ifndef _PreComp_ +#include +#include +#include +#include +#include +#include +#endif + +#include "ThumbnailSource.h" + +#include + +#include "FileUtilities.h" + +#include + +using namespace Start; + +ThumbnailSource::F3DInstallation ThumbnailSource::_f3d {}; +QMutex ThumbnailSource::_mutex; + +ThumbnailSource::ThumbnailSource(QString file) + : _file(std::move(file)) +{} + +ThumbnailSourceSignals* ThumbnailSource::signals() +{ + return &_signals; +} + +void ThumbnailSource::run() +{ + _thumbnailPath = getPathToCachedThumbnail(_file); + if (!useCachedThumbnail(_thumbnailPath, _file)) { + setupF3D(); // Go through the mutex to ensure data is not stale + if (_f3d.major < 2) { + return; + } + const ParameterGrp::handle hGrp = App::GetApplication().GetParameterGroupByPath( + "User parameter:BaseApp/Preferences/Mod/Start"); + const auto f3d = QString::fromUtf8(hGrp->GetASCII("f3d", "f3d").c_str()); + QStringList args(_f3d.baseArgs); + args << QLatin1String("--output=") + _thumbnailPath << _file; + + QProcess process; + Base::Console().Log("Creating thumbnail for %s...\n", _file.toStdString()); + process.start(f3d, args); + if (!process.waitForFinished()) { + process.kill(); + Base::Console().Log("Creating thumbnail for %s timed out\n", _file.toStdString()); + return; + } + if (process.exitStatus() == QProcess::CrashExit) { + Base::Console().Log("Creating thumbnail for %s crashed\n", _file.toStdString()); + return; + } + if (process.exitCode() != 0) { + Base::Console().Log("Creating thumbnail for %s failed\n", _file.toStdString()); + return; + } + Base::Console().Log("Creating thumbnail for %s succeeded, wrote to %s\n", + _file.toStdString(), + _thumbnailPath.toStdString()); + } + if (QFile thumbnailFile(_thumbnailPath); thumbnailFile.exists()) { + thumbnailFile.open(QIODevice::OpenModeFlag::ReadOnly); + Q_EMIT _signals.thumbnailAvailable(_file, thumbnailFile.readAll()); + } +} + +std::tuple extractF3DVersion(const QString& stdoutString) +{ + int major {0}; + int minor {0}; + int patch {0}; + for (auto lines = stdoutString.split(QLatin1Char('\n')); const auto& line : lines) { + if (line.startsWith(QLatin1String("Version: "))) { + const auto substring = line.mid(8); + if (auto split = substring.split(QLatin1Char('.')); split.size() >= 3) { + try { + major = split[0].toInt(); + minor = split[1].toInt(); + patch = split[2].toInt(); + } + catch (...) { + Base::Console().Log( + "Could not determine F3D version, disabling thumbnail generation\n"); + } + } + break; + } + } + return std::make_tuple(major, minor, patch); +} + +QStringList getF3DOptions(const QString& f3d) +{ + // F3D is under active development, and the available options change with some regularity. + // Rather than hardcode per version, just check the ones we care about. + QStringList optionsToTest { + QStringLiteral("--load-plugins=occt"), + QStringLiteral("--config=thumbnail"), + QStringLiteral("--verbose=quiet"), + QStringLiteral("--resolution=256,256"), + QStringLiteral("--filename=0"), + QStringLiteral("--grid=0"), + QStringLiteral("--axis=0"), + QStringLiteral("--no-background"), + QStringLiteral("--max-size=100") // Max input file size in MB + }; + QStringList goodOptions; + for (const auto& option : optionsToTest) { + QStringList args; + args << option << QStringLiteral("--no-render"); + QProcess process; + process.start(f3d, args); + if (!process.waitForFinished()) { + process.kill(); + continue; + } + auto stderrAsBytes = process.readAllStandardError(); + if (auto stderrAsString = QString::fromUtf8(stderrAsBytes); + !stderrAsString.contains(QLatin1String("Unknown option"))) { + goodOptions.append(option); + } + } + return goodOptions; +} + +void ThumbnailSource::setupF3D() +{ + QMutexLocker locker(&_mutex); + if (_f3d.initialized) { + return; + } + + // This method makes repeated blocking calls to f3d (both directly, the call below, and + // indirectly, by calling getF3DOptions). By holding the mutex above, it ensures that these + // calls complete before any process can attempt to make a real call to f3d to create thumbnail + // data. ThumbnailSource is run in its own thread, so blocking here is appropriate and will not + // affect any other part of the program. + + _f3d.initialized = true; // Set immediately so we can use early-return below + const ParameterGrp::handle hGrp = App::GetApplication().GetParameterGroupByPath( + "User parameter:BaseApp/Preferences/Mod/Start"); + const auto f3d = QString::fromUtf8(hGrp->GetASCII("f3d", "f3d").c_str()); + const QStringList args {QLatin1String("--version")}; + QProcess process; + process.start(f3d, args); + if (!process.waitForFinished()) { + process.kill(); + } + if (process.exitCode() != 0) { + return; + } + const QByteArray stdoutBytes = process.readAllStandardOutput(); + const auto stdoutString = QString::fromUtf8(stdoutBytes); + const auto version = extractF3DVersion(stdoutString); + _f3d.major = std::get<0>(version); + _f3d.minor = std::get<1>(version); + if (_f3d.major >= 2) { + _f3d.baseArgs = getF3DOptions(f3d); + } + Base::Console().Log("Running f3d version %d.%d\n", _f3d.major, _f3d.minor); +} diff --git a/src/Mod/Start/App/ThumbnailSource.h b/src/Mod/Start/App/ThumbnailSource.h new file mode 100644 index 0000000000..17b491ba83 --- /dev/null +++ b/src/Mod/Start/App/ThumbnailSource.h @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 The FreeCAD Project Association AISBL * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD is distributed in the hope that it will be useful, but * + * WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#ifndef FREECAD_THUMBNAIL_SOURCE_H +#define FREECAD_THUMBNAIL_SOURCE_H + +#include +#include +#include +#include +#include +#include + +#include + +namespace Start +{ + +class ThumbnailSourceSignals: public QObject +{ + Q_OBJECT +public: +Q_SIGNALS: + + void thumbnailAvailable(const QString& file, const QByteArray& data); +}; + +class ThumbnailSource: public QRunnable +{ +public: + explicit ThumbnailSource(QString file); + + // Don't make copies of a ThumbnailSource (it's probably running a process, what would it mean + // to copy it?): + ThumbnailSource(ThumbnailSource&) = delete; + ThumbnailSource(ThumbnailSource&&) = delete; + ThumbnailSource operator=(const ThumbnailSource&) = delete; + ThumbnailSource operator=(ThumbnailSource&&) = delete; + + void run() override; + + ThumbnailSourceSignals* signals(); + +private: + static void setupF3D(); + + QString _file; + QString _thumbnailPath; + ThumbnailSourceSignals _signals; + + /// Gather together all of the f3d information protected by the mutex: data in this struct + /// should be accessed only after a call to setupF3D() to ensure synchronization. + static struct F3DInstallation + { + bool initialized {false}; + int major {0}; + int minor {0}; + QStringList baseArgs; + } _f3d; + static QMutex _mutex; +}; + +} // namespace Start + +#endif // FREECAD_THUMBNAIL_SOURCE_H diff --git a/src/Mod/Start/CMakeLists.txt b/src/Mod/Start/CMakeLists.txt index 4991bc3a98..14613a1e95 100644 --- a/src/Mod/Start/CMakeLists.txt +++ b/src/Mod/Start/CMakeLists.txt @@ -52,7 +52,7 @@ fc_target_copy_resource(StartScripts ${CMAKE_BINARY_DIR}/Mod/Start ${Start_Scripts}) -INSTALL( +install( FILES ${Start_Scripts} DESTINATION diff --git a/src/Mod/Start/Gui/FileCardDelegate.cpp b/src/Mod/Start/Gui/FileCardDelegate.cpp index 3fe602bf96..2a31b55428 100644 --- a/src/Mod/Start/Gui/FileCardDelegate.cpp +++ b/src/Mod/Start/Gui/FileCardDelegate.cpp @@ -149,6 +149,8 @@ QPixmap FileCardDelegate::generateThumbnail(const QString& path) const auto thumbnailSize = static_cast(_parameterGroup->GetInt("FileThumbnailIconsSize", 128)); // NOLINT if (path.endsWith(QLatin1String(".fcstd"), Qt::CaseSensitivity::CaseInsensitive)) { + // This is a fallback, the model will have pulled the thumbnail out of the FCStd file if it + // existed. QImageReader reader(QLatin1String(":/icons/freecad-doc.svg")); reader.setScaledSize({thumbnailSize, thumbnailSize}); return QPixmap::fromImage(reader.read()); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f66475e0ba..5d45e4d8d9 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -114,6 +114,9 @@ endif(BUILD_SKETCHER) if(BUILD_SPREADSHEET) list (APPEND TestExecutables Spreadsheet_tests_run) endif() +if(BUILD_START) + list (APPEND TestExecutables Start_tests_run) +endif() # ------------------------- diff --git a/tests/src/Mod/CMakeLists.txt b/tests/src/Mod/CMakeLists.txt index 4f7578400a..4b1ae053d3 100644 --- a/tests/src/Mod/CMakeLists.txt +++ b/tests/src/Mod/CMakeLists.txt @@ -28,3 +28,6 @@ endif(BUILD_SKETCHER) if(BUILD_SPREADSHEET) add_subdirectory(Spreadsheet) endif() +if(BUILD_START) + add_subdirectory(Start) +endif() diff --git a/tests/src/Mod/Start/App/CMakeLists.txt b/tests/src/Mod/Start/App/CMakeLists.txt new file mode 100644 index 0000000000..4220b32b8e --- /dev/null +++ b/tests/src/Mod/Start/App/CMakeLists.txt @@ -0,0 +1,12 @@ +target_sources( + Start_tests_run + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/FileUtilities.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ThumbnailSource.cpp +) + +target_include_directories( + Start_tests_run + PUBLIC + ${CMAKE_BINARY_DIR} +) diff --git a/tests/src/Mod/Start/App/FileUtilities.cpp b/tests/src/Mod/Start/App/FileUtilities.cpp new file mode 100644 index 0000000000..f2de0026a3 --- /dev/null +++ b/tests/src/Mod/Start/App/FileUtilities.cpp @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later + +#include +#include "src/App/InitApplication.h" + +#include + +#include + + +class FileUtilitiesTest: public ::testing::Test +{ +protected: + static void SetUpTestSuite() + { + tests::initApplication(); + } + + void SetUp() override + {} + + void TearDown() override + {} + + +private: +}; + +TEST_F(FileUtilitiesTest, humanReadableSizeZeroBytes) +{ + auto result = Start::humanReadableSize(0); + EXPECT_EQ(result, "0 B"); +} + +TEST_F(FileUtilitiesTest, humanReadableSizeBytesHasNoDecimal) +{ + constexpr uint64_t smallNumberOfBytes {512}; + auto result = Start::humanReadableSize(smallNumberOfBytes); + EXPECT_EQ(result.find('.'), std::string::npos); +} + +TEST_F(FileUtilitiesTest, humanReadableSizeOthersHaveDecimal) +{ + constexpr std::array testSizes {1000, + 123456, + 123456789, + 123456789013, + 100000000000000, + std::numeric_limits::max()}; + for (const auto size : testSizes) { + auto result = Start::humanReadableSize(size); + EXPECT_NE(result.find('.'), std::string::npos); + } +} + +TEST_F(FileUtilitiesTest, humanReadableSizeB) +{ + EXPECT_EQ("999 B", Start::humanReadableSize(999)); +} + +TEST_F(FileUtilitiesTest, humanReadableSizekB) +{ + EXPECT_EQ("1.0 kB", Start::humanReadableSize(1000)); +} + +TEST_F(FileUtilitiesTest, humanReadableSizekBSignificantDigits) +{ + EXPECT_EQ(Start::humanReadableSize(1000), Start::humanReadableSize(1001)); +} + +TEST_F(FileUtilitiesTest, humanReadableSizeMB) +{ + EXPECT_EQ("2.5 MB", Start::humanReadableSize(2.5e6)); +} + +TEST_F(FileUtilitiesTest, humanReadableSizeGB) +{ + EXPECT_EQ("3.7 GB", Start::humanReadableSize(3.7e9)); +} + +TEST_F(FileUtilitiesTest, humanReadableSizeTB) +{ + EXPECT_EQ("444.8 TB", Start::humanReadableSize(444.823e12)); +} + +TEST_F(FileUtilitiesTest, humanReadableSizePB) +{ + EXPECT_EQ("1.2 PB", Start::humanReadableSize(1.2e15)); +} + +TEST_F(FileUtilitiesTest, humanReadableSizeEB) +{ + EXPECT_EQ("7.3 EB", Start::humanReadableSize(7.3e18)); +} diff --git a/tests/src/Mod/Start/App/ThumbnailSource.cpp b/tests/src/Mod/Start/App/ThumbnailSource.cpp new file mode 100644 index 0000000000..b9161c41c3 --- /dev/null +++ b/tests/src/Mod/Start/App/ThumbnailSource.cpp @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later + +#include +#include "src/App/InitApplication.h" + +#include + +#include + +class ThumbnailSourceTest: public ::testing::Test +{ +protected: + static void SetUpTestSuite() + { + tests::initApplication(); + } + + void SetUp() override + {} + + void TearDown() override + {} + + +private: +}; diff --git a/tests/src/Mod/Start/CMakeLists.txt b/tests/src/Mod/Start/CMakeLists.txt new file mode 100644 index 0000000000..62680aa490 --- /dev/null +++ b/tests/src/Mod/Start/CMakeLists.txt @@ -0,0 +1,12 @@ + +target_include_directories(Start_tests_run PUBLIC + ${Python3_INCLUDE_DIRS} +) + +target_link_libraries(Start_tests_run + gtest_main + ${Google_Tests_LIBS} + Start +) + +add_subdirectory(App)