diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..da3a444 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + name: Build & Test / ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: Linux + os: ubuntu-22.04 + qt-arch: "" + - name: Windows + os: windows-latest + qt-arch: win64_msvc2022_64 + - name: macOS + os: macos-latest + qt-arch: clang_64 + + steps: + - uses: actions/checkout@v4 + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: "6.10.2" + arch: ${{ matrix.qt-arch }} + modules: "qtmultimedia" + cache: true + + - name: Platform dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update -q + sudo apt-get install -y ninja-build libeigen3-dev libgl1-mesa-dev libglu1-mesa-dev + + - name: Platform dependencies (macOS) + if: runner.os == 'macOS' + run: brew install ninja eigen + + - name: Platform dependencies (Windows) + if: runner.os == 'Windows' + run: choco install ninja --no-progress + + - name: Set up MSVC (Windows) + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + + - name: Configure + run: cmake --preset test + + - name: Build + run: cmake --build --preset test + + - name: Test + run: ctest --preset test diff --git a/.gitignore b/.gitignore index 0c1d65c..937a453 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ build/ sounds/ -dist/ \ No newline at end of file +dist/ +*_out.txt +*_output.txt +CLAUDE.md \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 5053b41..26e308e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,10 +20,20 @@ if(NOT Eigen3_FOUND) GIT_TAG 3.4.0 GIT_SHALLOW TRUE ) - set(EIGEN_BUILD_DOC OFF CACHE BOOL "" FORCE) - set(BUILD_TESTING OFF CACHE BOOL "" FORCE) - set(EIGEN_BUILD_PKGCONFIG OFF CACHE BOOL "" FORCE) - FetchContent_MakeAvailable(eigen) + # Use Populate (not MakeAvailable) to skip Eigen's CMakeLists.txt entirely. + # MakeAvailable triggers BLAS detection which calls enable_language(Fortran) + # and fails on CI runners that have a broken gfortran in PATH. + # Eigen is header-only for our use; no Fortran is needed. + FetchContent_GetProperties(eigen) + if(NOT eigen_POPULATED) + if(POLICY CMP0168) + cmake_policy(SET CMP0168 OLD) # suppress FetchContent_Populate deprecation (CMake 3.30+) + endif() + FetchContent_Populate(eigen) + endif() + add_library(Eigen3::Eigen INTERFACE IMPORTED GLOBAL) + target_include_directories(Eigen3::Eigen INTERFACE "${eigen_SOURCE_DIR}") + set(EIGEN3_INCLUDE_DIR "${eigen_SOURCE_DIR}") endif() find_package(OpenMP) @@ -67,6 +77,7 @@ set(SOURCES src/acoustics/RoomImpulseResponse.cpp src/acoustics/Wall.cpp src/acoustics/SimulationWorker.cpp + src/acoustics/RenderExports.cpp src/acoustics/SimulationQueue.cpp src/acoustics/AcousticMetrics.cpp src/acoustics/dg/DGBasis2D.cpp @@ -121,6 +132,9 @@ set(HEADERS src/acoustics/RoomImpulseResponse.h src/acoustics/Wall.h src/acoustics/SimulationWorker.h + src/acoustics/RenderOptions.h + src/acoustics/RenderExports.h + src/acoustics/RenderPipeline.h src/acoustics/SimulationQueue.h src/acoustics/AcousticMetrics.h src/acoustics/dg/DGTypes.h @@ -221,5 +235,94 @@ install(TARGETS ${PROJECT_NAME} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} ) +# ── Headless CLI render tool ───────────────────────────────────────────────── +set(CLI_SOURCES + src/cli/main.cpp + src/core/Material.cpp + src/core/MaterialLoader.cpp + src/core/SoundSource.cpp + src/core/Listener.cpp + src/core/PlacedPoint.cpp + src/core/ProjectFile.cpp + src/scene/SceneManager.cpp + src/rendering/Camera.cpp + src/rendering/MeshData.cpp + src/rendering/SurfaceGrouper.cpp + src/rendering/RayPicking.cpp + src/rendering/MeshSimplifier.cpp + src/rendering/TextureManager.cpp + src/acoustics/AcousticSimulator.cpp + src/acoustics/Bvh.cpp + src/acoustics/ImageSourceMethod.cpp + src/acoustics/RayTracer.cpp + src/acoustics/RoomImpulseResponse.cpp + src/acoustics/Wall.cpp + src/acoustics/SimulationWorker.cpp + src/acoustics/RenderExports.cpp + src/acoustics/RenderPipeline.cpp + src/acoustics/AcousticMetrics.cpp + src/acoustics/dg/DGBasis2D.cpp + src/acoustics/dg/DGBasis3D.cpp + src/acoustics/dg/DGMesh2D.cpp + src/acoustics/dg/DGMesh3D.cpp + src/acoustics/dg/DGAcoustics2D.cpp + src/acoustics/dg/DGAcoustics3D.cpp + src/acoustics/dg/DGSolver.cpp + src/acoustics/dg/DGGpuCompute.cpp + src/audio/AudioFile.cpp + src/audio/SignalProcessing.cpp + src/utils/ResourcePath.cpp +) + +add_executable(SeicheCLI ${CLI_SOURCES}) + +target_include_directories(SeicheCLI PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${EIGEN3_INCLUDE_DIR} +) + +target_link_libraries(SeicheCLI PRIVATE + Qt6::Widgets + Qt6::OpenGL + Qt6::OpenGLWidgets + Qt6::Multimedia + Qt6::Svg + Eigen3::Eigen +) + +if(OpenMP_CXX_FOUND) + target_link_libraries(SeicheCLI PRIVATE OpenMP::OpenMP_CXX) +endif() + +if(SNDFILE_LIBRARIES) + target_include_directories(SeicheCLI PRIVATE ${SNDFILE_INCLUDE_DIRS}) + target_link_libraries(SeicheCLI PRIVATE ${SNDFILE_LIBRARIES}) + target_compile_definitions(SeicheCLI PRIVATE HAS_SNDFILE) +endif() + +if(FFTW3_LIBRARIES) + target_include_directories(SeicheCLI PRIVATE ${FFTW3_INCLUDE_DIRS}) + target_link_libraries(SeicheCLI PRIVATE ${FFTW3_LIBRARIES}) + target_compile_definitions(SeicheCLI PRIVATE HAS_FFTW3) +endif() + +if(WIN32) + target_link_libraries(SeicheCLI PRIVATE opengl32 glu32) +elseif(APPLE) + find_package(OpenGL REQUIRED) + target_link_libraries(SeicheCLI PRIVATE OpenGL::GL OpenGL::GLU) +else() + find_package(OpenGL REQUIRED) + target_link_libraries(SeicheCLI PRIVATE OpenGL::GL OpenGL::GLU) +endif() + +target_compile_definitions(SeicheCLI PRIVATE _USE_MATH_DEFINES) + +install(TARGETS SeicheCLI + BUNDLE DESTINATION . + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) + enable_testing() add_subdirectory(tests) diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..898313a --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,62 @@ +{ + "version": 6, + "cmakeMinimumRequired": { "major": 3, "minor": 21, "patch": 0 }, + "configurePresets": [ + { + "name": "base", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}" + }, + { + "name": "debug", + "inherits": "base", + "displayName": "Debug", + "description": "Debug build with symbols and assertions", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, + { + "name": "release", + "inherits": "base", + "displayName": "Release", + "description": "Optimised release build", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + }, + { + "name": "test", + "inherits": "debug", + "displayName": "Test", + "description": "Debug build used for running the Qt test suite" + } + ], + "buildPresets": [ + { + "name": "debug", + "configurePreset": "debug" + }, + { + "name": "release", + "configurePreset": "release" + }, + { + "name": "test", + "configurePreset": "test" + } + ], + "testPresets": [ + { + "name": "test", + "configurePreset": "test", + "output": { + "outputOnFailure": true + }, + "environment": { + "QT_QPA_PLATFORM": "offscreen" + } + } + ] +} diff --git a/build_macos.sh b/build_macos.sh index 82ef2d4..dc0b720 100644 --- a/build_macos.sh +++ b/build_macos.sh @@ -1,6 +1,43 @@ -cmake -S . -B build \ - -DCMAKE_PREFIX_PATH="" \ - -DCMAKE_BUILD_TYPE=Release \ - -G Ninja +#!/usr/bin/env bash +set -euo pipefail -cmake --build build +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +QT_PREFIX_PATH="${QT_PREFIX_PATH:-}" +CMAKE_GENERATOR="${CMAKE_GENERATOR:-Ninja}" + +# Auto-detect Qt prefix from macdeployqt if not explicitly set +if [[ -z "${QT_PREFIX_PATH}" ]]; then + if command -v macdeployqt >/dev/null 2>&1; then + QT_PREFIX_PATH="$(cd "$(dirname "$(command -v macdeployqt)")/.." && pwd)" + fi +fi + +if [[ -z "${QT_PREFIX_PATH}" ]]; then + echo "Error: QT_PREFIX_PATH is not set and could not be inferred from macdeployqt." >&2 + echo "Set QT_PREFIX_PATH to your Qt macOS prefix (e.g. ~/Qt/6.x/macos)." >&2 + exit 1 +fi + +if [[ ! -d "${QT_PREFIX_PATH}" ]]; then + echo "Error: QT_PREFIX_PATH does not exist: ${QT_PREFIX_PATH}" >&2 + exit 1 +fi + +# Optionally pass Qt6_DIR if available +CMAKE_QT6_DIR_ARGS=() +if [[ -n "${Qt6_DIR:-}" && -d "${Qt6_DIR}" ]]; then + CMAKE_QT6_DIR_ARGS=( -DQt6_DIR="${Qt6_DIR}" ) +elif [[ -d "${QT_PREFIX_PATH}/lib/cmake/Qt6" ]]; then + CMAKE_QT6_DIR_ARGS=( -DQt6_DIR="${QT_PREFIX_PATH}/lib/cmake/Qt6" ) +fi + +cmake -S "${REPO_ROOT}" -B "${REPO_ROOT}/build" \ + -DCMAKE_PREFIX_PATH="${QT_PREFIX_PATH}" \ + -DCMAKE_BUILD_TYPE=Release \ + -G "${CMAKE_GENERATOR}" \ + "${CMAKE_QT6_DIR_ARGS[@]}" + +cmake --build "${REPO_ROOT}/build" --config Release + +echo "Build complete. Run: ./build/Seiche.app/Contents/MacOS/Seiche" diff --git a/build_windows.sh b/build_windows.sh index 48bf6a2..62656ce 100644 --- a/build_windows.sh +++ b/build_windows.sh @@ -1,8 +1,14 @@ #!/usr/bin/env bash set -euo pipefail -QT_ROOT="C:/Qt/6.10.2/mingw_64" -MINGW_BIN="C:/Qt/Tools/mingw1310_64/bin" +QT_ROOT="${QT_ROOT:-C:/Qt/6.10.2/mingw_64}" +MINGW_BIN="${MINGW_BIN:-C:/Qt/Tools/mingw1310_64/bin}" +QT_CMAKE="${QT_CMAKE:-C:/Qt/Tools/CMake_64/bin}" +QT_NINJA="${QT_NINJA:-C:/Qt/Tools/Ninja}" + +# Prefer Qt-bundled cmake/ninja and the project's MinGW over anything else +# (avoids conflicts with e.g. Strawberry Perl's cmake). +export PATH="${QT_CMAKE}:${QT_NINJA}:${MINGW_BIN}:${PATH}" # rm -rf build diff --git a/release_macos.sh b/release_macos.sh index 6f05821..b087ea1 100644 --- a/release_macos.sh +++ b/release_macos.sh @@ -8,22 +8,8 @@ PROJECT_NAME="${PROJECT_NAME:-Seiche}" BUILD_DIR="${BUILD_DIR:-build}" DIST_DIR="${DIST_DIR:-dist}" -cleanup_build=false -if [[ "${1:-}" == "--fresh" ]]; then - cleanup_build=true -fi - -if [[ "$cleanup_build" == "true" ]]; then - rm -rf "${REPO_ROOT:?}/${BUILD_DIR}" -fi - -# Build (uses Qt paths to configure CMake) -# -# Examples: -# QT_PREFIX_PATH="/path/to/Qt/6.x/macos" ./release_macos.sh -# CMAKE_GENERATOR="Ninja" ./release_macos.sh -# -# If QT_PREFIX_PATH is not set, the script will try to infer it from `macdeployqt`. +# Resolve QT_PREFIX_PATH before calling the build script so the child process +# inherits the same value (auto-detect from macdeployqt if not explicitly set). if [[ -z "${QT_PREFIX_PATH}" ]]; then if command -v macdeployqt >/dev/null 2>&1; then QT_PREFIX_PATH="$(cd "$(dirname "$(command -v macdeployqt)")/.." && pwd)" @@ -32,33 +18,23 @@ fi if [[ -z "${QT_PREFIX_PATH}" ]]; then echo "Error: QT_PREFIX_PATH is not set and could not be inferred from macdeployqt." >&2 - echo "Set QT_PREFIX_PATH to your Qt macOS prefix (e.g. /path/to/Qt/6.x/macos)." >&2 + echo "Set QT_PREFIX_PATH to your Qt macOS prefix (e.g. ~/Qt/6.x/macos)." >&2 exit 1 fi -if [[ ! -d "${QT_PREFIX_PATH}" ]]; then - echo "Error: QT_PREFIX_PATH does not exist: ${QT_PREFIX_PATH}" >&2 - exit 1 +export QT_PREFIX_PATH + +cleanup_build=false +if [[ "${1:-}" == "--fresh" ]]; then + cleanup_build=true fi -CMAKE_GENERATOR="${CMAKE_GENERATOR:-Ninja}" -if [[ -n "${Qt6_DIR:-}" && -d "${Qt6_DIR}" ]]; then - CMAKE_QT6_DIR_ARGS=( -DQt6_DIR="${Qt6_DIR}" ) -else - QT6_DIR_CANDIDATE="${QT_PREFIX_PATH}/lib/cmake/Qt6" - if [[ -d "${QT6_DIR_CANDIDATE}" ]]; then - CMAKE_QT6_DIR_ARGS=( -DQt6_DIR="${QT6_DIR_CANDIDATE}" ) - else - CMAKE_QT6_DIR_ARGS=() - fi +if [[ "$cleanup_build" == "true" ]]; then + rm -rf "${REPO_ROOT:?}/${BUILD_DIR}" fi -cmake -S "${REPO_ROOT}" -B "${REPO_ROOT}/${BUILD_DIR}" \ - -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_PREFIX_PATH="${QT_PREFIX_PATH}" \ - -G "${CMAKE_GENERATOR}" \ - "${CMAKE_QT6_DIR_ARGS[@]}" -cmake --build "${REPO_ROOT}/${BUILD_DIR}" --config Release +# Build via the shared build script (inherits exported QT_PREFIX_PATH) +"${REPO_ROOT}/build_macos.sh" PROJECT_VERSION="$(sed -n 's/^project([^ ]* VERSION \([^ ]*\) LANGUAGES.*$/\1/p' "${REPO_ROOT}/CMakeLists.txt")" if [[ -z "${PROJECT_VERSION}" ]]; then diff --git a/release_windows.sh b/release_windows.sh index 988a927..c8627ef 100644 --- a/release_windows.sh +++ b/release_windows.sh @@ -4,10 +4,16 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" QT_PREFIX_PATH="${QT_PREFIX_PATH:-C:/Qt/6.10.2/mingw_64}" +QT_CMAKE="${QT_CMAKE:-C:/Qt/Tools/CMake_64/bin}" +QT_NINJA="${QT_NINJA:-C:/Qt/Tools/Ninja}" +MINGW_BIN="${MINGW_BIN:-C:/Qt/Tools/mingw1310_64/bin}" PROJECT_NAME="${PROJECT_NAME:-Seiche}" BUILD_DIR="${BUILD_DIR:-build}" DIST_DIR="${DIST_DIR:-dist}" +# Prefer Qt-bundled cmake/ninja and the project's MinGW over anything else +export PATH="${QT_CMAKE}:${QT_NINJA}:${MINGW_BIN}:${PATH}" + cleanup_build=false if [[ "${1:-}" == "--fresh" ]]; then cleanup_build=true diff --git a/src/acoustics/AcousticSimulator.cpp b/src/acoustics/AcousticSimulator.cpp index 04b8bb4..5becd92 100644 --- a/src/acoustics/AcousticSimulator.cpp +++ b/src/acoustics/AcousticSimulator.cpp @@ -8,6 +8,7 @@ #include "audio/AudioFile.h" #include "audio/SignalProcessing.h" +#include #include #include #include @@ -96,7 +97,8 @@ QString AcousticSimulator::simulateScene( << "Listeners:" << scene.listenerCount(); QString timestamp = QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm-ss"); - QString outputDir = QDir("sounds/simulations").filePath("simulation_" + timestamp); + QString appDir = QCoreApplication::applicationDirPath(); + QString outputDir = QDir(appDir).filePath("outputs/simulation_" + timestamp); QDir().mkpath(outputDir); scene.saveToFile(QDir(outputDir).filePath("scene.json")); diff --git a/src/acoustics/RenderExports.cpp b/src/acoustics/RenderExports.cpp new file mode 100644 index 0000000..f0a425a --- /dev/null +++ b/src/acoustics/RenderExports.cpp @@ -0,0 +1,99 @@ +#include "RenderExports.h" + +#include "audio/AudioFile.h" + +#include +#include + +namespace prs::RenderExports { + +namespace { + +QString csvEscape(const QString& value) { + QString out = value; + out.replace('"', "\"\""); + if (out.contains(',') || out.contains('"') || out.contains('\n')) + return '"' + out + '"'; + return out; +} + +QString metricString(const QJsonObject& pair, const char* section, const char* key) { + if (!pair.contains(section) || !pair.value(section).isObject()) + return {}; + const QJsonObject obj = pair.value(section).toObject(); + if (!obj.contains(key)) + return {}; + return QString::number(obj.value(key).toDouble(), 'f', 6); +} + +} // namespace + +bool saveMonoWav(const QString& path, int sampleRate, const std::vector& samples) { + AudioFile file; + file.samples() = samples; + return file.save(path, sampleRate); +} + +bool saveStereoWav(const QString& path, int sampleRate, const std::vector& left, + const std::vector& right) { + return AudioFile::saveStereo(path, sampleRate, left, right); +} + +QJsonObject buildMetricsSummary(const QJsonArray& pairs, int sampleRate) { + QJsonObject summary; + summary["version"] = "1.0.0"; + summary["sample_rate"] = sampleRate; + summary["pair_count"] = pairs.size(); + return summary; +} + +bool saveJsonObject(const QString& path, const QJsonObject& obj) { + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return false; + file.write(QJsonDocument(obj).toJson(QJsonDocument::Indented)); + return true; +} + +bool saveMetricsCsv(const QString& path, const QJsonArray& pairs) { + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) + return false; + + QByteArray contents; + contents += "source,listener,t20,t30,edt,direct_dB,reflected_dB,total_dB\n"; + for (const auto& pairVal : pairs) { + if (!pairVal.isObject()) + continue; + const QJsonObject pair = pairVal.toObject(); + const QString source = csvEscape(pair.value("source").toString()); + const QString listener = csvEscape(pair.value("listener").toString()); + const QString t20 = metricString(pair, "reverberation_time", "T20"); + const QString t30 = metricString(pair, "reverberation_time", "T30"); + const QString edt = metricString(pair, "reverberation_time", "EDT"); + const QString direct = metricString(pair, "sound_pressure_level", "direct_dB"); + const QString reflected = metricString(pair, "sound_pressure_level", "reflected_dB"); + const QString total = metricString(pair, "sound_pressure_level", "total_dB"); + + contents += source.toUtf8(); + contents += ','; + contents += listener.toUtf8(); + contents += ','; + contents += t20.toUtf8(); + contents += ','; + contents += t30.toUtf8(); + contents += ','; + contents += edt.toUtf8(); + contents += ','; + contents += direct.toUtf8(); + contents += ','; + contents += reflected.toUtf8(); + contents += ','; + contents += total.toUtf8(); + contents += '\n'; + } + + return file.write(contents) == contents.size(); +} + +} // namespace prs::RenderExports diff --git a/src/acoustics/RenderExports.h b/src/acoustics/RenderExports.h new file mode 100644 index 0000000..5a3d891 --- /dev/null +++ b/src/acoustics/RenderExports.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include + +#include + +namespace prs::RenderExports { + +bool saveMonoWav(const QString& path, int sampleRate, const std::vector& samples); +bool saveStereoWav(const QString& path, int sampleRate, const std::vector& left, + const std::vector& right); + +QJsonObject buildMetricsSummary(const QJsonArray& pairs, int sampleRate); +bool saveJsonObject(const QString& path, const QJsonObject& obj); +bool saveMetricsCsv(const QString& path, const QJsonArray& pairs); + +} // namespace prs::RenderExports diff --git a/src/acoustics/RenderOptions.h b/src/acoustics/RenderOptions.h new file mode 100644 index 0000000..5ab47f3 --- /dev/null +++ b/src/acoustics/RenderOptions.h @@ -0,0 +1,24 @@ +#pragma once + +#include "core/Types.h" + +namespace prs { + +enum class RenderMethod { + RayTracing, + DG_2D, + DG_3D, +}; + +struct RenderOptions { + RenderMethod method = RenderMethod::RayTracing; + int maxOrder = DEFAULT_MAX_ORDER; + int nRays = DEFAULT_N_RAYS; + float scattering = DEFAULT_SCATTERING; + bool airAbsorption = true; + int sampleRate = DEFAULT_SAMPLE_RATE; + int dgPolyOrder = 3; + float dgMaxFrequency = 1000.0f; +}; + +} // namespace prs diff --git a/src/acoustics/RenderPipeline.cpp b/src/acoustics/RenderPipeline.cpp new file mode 100644 index 0000000..f8121fd --- /dev/null +++ b/src/acoustics/RenderPipeline.cpp @@ -0,0 +1,119 @@ +#include "RenderPipeline.h" + +#include "rendering/MeshData.h" +#include "rendering/SurfaceGrouper.h" + +#include +#include + +namespace prs::RenderPipeline { + +namespace { + +QString resolveRelativePath(const QString& basePath, const QString& candidate) { + if (candidate.isEmpty()) + return {}; + QFileInfo info(candidate); + if (info.isAbsolute()) + return info.filePath(); + return QFileInfo(basePath).dir().filePath(candidate); +} + +SimMethod toSimMethod(RenderMethod method) { + switch (method) { + case RenderMethod::DG_2D: + return SimMethod::DG_2D; + case RenderMethod::DG_3D: + return SimMethod::DG_3D; + case RenderMethod::RayTracing: + default: + return SimMethod::RayTracing; + } +} + +std::vector buildWalls(const MeshData& mesh, const ProjectData& project) { + auto featureEdges = SurfaceGrouper::computeFeatureEdges(mesh, 10.0f); + auto surfaces = SurfaceGrouper::groupTrianglesIntoSurfaces(mesh, featureEdges); + std::vector walls; + walls.reserve(surfaces.size()); + + for (int si = 0; si < static_cast(surfaces.size()); ++si) { + Viewport3D::WallInfo wi; + wi.triangleIndices.assign(surfaces[si].begin(), surfaces[si].end()); + if (si < static_cast(project.surfaceMaterials.size()) && project.surfaceMaterials[si].has_value()) { + wi.absorption = project.surfaceMaterials[si]->absorption; + wi.scattering = project.surfaceMaterials[si]->scattering; + } + walls.push_back(std::move(wi)); + } + + return walls; +} + +SceneManager buildScene(const ProjectData& project, const QString& projectPath, const QString& defaultAudioFile) { + SceneManager scene; + const float scale = project.scaleFactor; + const QString fallbackAudio = resolveRelativePath(projectPath, defaultAudioFile); + + int sourceIndex = 0; + int listenerIndex = 0; + for (const auto& pt : project.placedPoints) { + const Vec3f pos = pt.getPosition() * scale; + if (pt.pointType == POINT_TYPE_SOURCE) { + const QString audio = resolveRelativePath(projectPath, QString::fromStdString(pt.audioFile)); + const std::string file = audio.isEmpty() ? fallbackAudio.toStdString() : audio.toStdString(); + const std::string name = pt.name.empty() ? "Source " + std::to_string(++sourceIndex) : pt.name; + scene.addSoundSource(pos, file, pt.volume, name); + } else if (pt.pointType == POINT_TYPE_LISTENER) { + const std::string name = pt.name.empty() ? "Listener " + std::to_string(++listenerIndex) : pt.name; + scene.addListener(pos, name, pt.getForwardDirection()); + } + } + + return scene; +} + +} // namespace + +bool buildSimulationParams(const QString& projectPath, const ProjectData& project, const RenderOptions& options, + const std::vector& selectedListenerIndices, const QString& outputDir, + SimulationWorker::Params* params, QString* error) { + if (!params) { + if (error) + *error = "internal error: params output is null"; + return false; + } + + const QString meshPath = resolveRelativePath(projectPath, project.stlFilePath); + if (meshPath.isEmpty()) { + if (error) + *error = "project does not specify a room mesh"; + return false; + } + + MeshData mesh; + if (!mesh.load(meshPath)) { + if (error) + *error = QString("failed to load room mesh: %1").arg(meshPath); + return false; + } + + params->scene = buildScene(project, projectPath, project.soundSourceFile); + params->walls = buildWalls(mesh, project); + params->roomCenter = mesh.center() * project.scaleFactor; + params->modelVertices = mesh.scaledFlatVertices(project.scaleFactor); + params->sampleRate = options.sampleRate; + params->maxOrder = options.maxOrder; + params->nRays = options.nRays; + params->scattering = options.scattering; + params->airAbsorption = options.airAbsorption; + params->selectedListenerIndices = selectedListenerIndices; + params->method = toSimMethod(options.method); + params->dgPolyOrder = options.dgPolyOrder; + params->dgMaxFrequency = options.dgMaxFrequency; + params->outputDir = outputDir; + + return true; +} + +} // namespace prs::RenderPipeline diff --git a/src/acoustics/RenderPipeline.h b/src/acoustics/RenderPipeline.h new file mode 100644 index 0000000..42106ea --- /dev/null +++ b/src/acoustics/RenderPipeline.h @@ -0,0 +1,17 @@ +#pragma once + +#include "acoustics/SimulationWorker.h" +#include "core/ProjectFile.h" +#include "RenderOptions.h" + +#include + +#include + +namespace prs::RenderPipeline { + +bool buildSimulationParams(const QString& projectPath, const ProjectData& project, const RenderOptions& options, + const std::vector& selectedListenerIndices, const QString& outputDir, + SimulationWorker::Params* params, QString* error); + +} // namespace prs::RenderPipeline diff --git a/src/acoustics/SimulationWorker.cpp b/src/acoustics/SimulationWorker.cpp index d04a493..a7cc1ee 100644 --- a/src/acoustics/SimulationWorker.cpp +++ b/src/acoustics/SimulationWorker.cpp @@ -4,6 +4,7 @@ #include "ImageSourceMethod.h" #include "RayTracer.h" #include "RoomImpulseResponse.h" +#include "RenderExports.h" #include "AcousticMetrics.h" #include "dg/DGSolver.h" #include "rendering/RayPicking.h" @@ -11,6 +12,7 @@ #include "audio/AudioFile.h" #include "audio/SignalProcessing.h" +#include #include #include #include @@ -185,8 +187,12 @@ void SimulationWorker::process() { if (isCancelled()) { emit error("Cancelled"); return; } - QString timestamp = QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm-ss"); - QString outputDir = QDir("sounds/simulations").filePath("simulation_" + timestamp); + QString outputDir = params_.outputDir; + if (outputDir.isEmpty()) { + QString timestamp = QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm-ss"); + QString appDir = QCoreApplication::applicationDirPath(); + outputDir = QDir(appDir).filePath("outputs/simulation_" + timestamp); + } QDir().mkpath(outputDir); scene.saveToFile(QDir(outputDir).filePath("scene.json")); @@ -263,9 +269,7 @@ void SimulationWorker::process() { QString listenerName = QString::fromStdString(listener->name).replace(' ', '_'); QString sourceName = QString::fromStdString(source->name).replace(' ', '_'); QString filename = QString("%1_from_%2.wav").arg(listenerName, sourceName); - AudioFile outFile; - outFile.samples() = std::move(outMono); - if (!outFile.save(QDir(outputDir).filePath(filename), fs)) + if (!RenderExports::saveMonoWav(QDir(outputDir).filePath(filename), fs, outMono)) qWarning() << "Failed to write" << filename; ++pairsDoneDG; @@ -402,7 +406,7 @@ void SimulationWorker::process() { QString listenerName = QString::fromStdString(listener->name).replace(' ', '_'); QString sourceName = QString::fromStdString(source->name).replace(' ', '_'); QString filename = QString("%1_from_%2.wav").arg(listenerName, sourceName); - if (!AudioFile::saveStereo(QDir(outputDir).filePath(filename), fs, outLeft, outRight)) + if (!RenderExports::saveStereoWav(QDir(outputDir).filePath(filename), fs, outLeft, outRight)) qWarning() << "Failed to write" << filename; MixedStereo& mix = mixedPerListener[li]; @@ -430,23 +434,23 @@ void SimulationWorker::process() { for (float& s : m.right) s /= maxVal; } QString listenerName = QString::fromStdString(listener->name).replace(' ', '_'); - AudioFile::saveStereo(QDir(outputDir).filePath(listenerName + "_mixed.wav"), mixedFs, m.left, m.right); + RenderExports::saveStereoWav(QDir(outputDir).filePath(listenerName + "_mixed.wav"), mixedFs, m.left, m.right); } } // Save metrics JSON if (!metricsArray.isEmpty()) { QJsonObject metricsRoot; - metricsRoot["version"] = "1.0"; + metricsRoot["version"] = "1.0.0"; metricsRoot["timestamp"] = QDateTime::currentDateTime().toString(Qt::ISODate); metricsRoot["sample_rate"] = params_.sampleRate; metricsRoot["pairs"] = metricsArray; - QFile metricsFile(QDir(outputDir).filePath("metrics.json")); - if (metricsFile.open(QIODevice::WriteOnly)) { - metricsFile.write(QJsonDocument(metricsRoot).toJson(QJsonDocument::Indented)); + if (RenderExports::saveJsonObject(QDir(outputDir).filePath("metrics.json"), metricsRoot)) qInfo() << "Saved metrics for" << metricsArray.size() << "source-listener pairs"; - } + RenderExports::saveJsonObject(QDir(outputDir).filePath("summary.json"), + RenderExports::buildMetricsSummary(metricsArray, params_.sampleRate)); + RenderExports::saveMetricsCsv(QDir(outputDir).filePath("metrics.csv"), metricsArray); } qInfo() << "=== SIMULATION COMPLETE in" << totalTimer.elapsed() << "ms ==="; diff --git a/src/acoustics/SimulationWorker.h b/src/acoustics/SimulationWorker.h index c686696..8b7995c 100644 --- a/src/acoustics/SimulationWorker.h +++ b/src/acoustics/SimulationWorker.h @@ -28,6 +28,7 @@ class SimulationWorker : public QObject { float scattering = DEFAULT_SCATTERING; bool airAbsorption = true; std::vector selectedListenerIndices; + QString outputDir; // if empty, a timestamped subdirectory is created automatically SimMethod method = SimMethod::RayTracing; int dgPolyOrder = 3; diff --git a/src/cli/main.cpp b/src/cli/main.cpp new file mode 100644 index 0000000..d1f8158 --- /dev/null +++ b/src/cli/main.cpp @@ -0,0 +1,143 @@ +#include "acoustics/RenderOptions.h" +#include "acoustics/RenderPipeline.h" +#include "acoustics/SimulationWorker.h" +#include "core/ProjectFile.h" + +#include +#include +#include +#include +#include + +#include + +namespace { + +bool parseOnOff(const QString& value, bool* out) { + const QString normalized = value.trimmed().toLower(); + if (normalized == "on" || normalized == "true" || normalized == "1") { + *out = true; + return true; + } + if (normalized == "off" || normalized == "false" || normalized == "0") { + *out = false; + return true; + } + return false; +} + +bool parseMethod(const QString& value, prs::RenderMethod* out) { + const QString normalized = value.trimmed().toLower(); + if (normalized == "ray") { + *out = prs::RenderMethod::RayTracing; + return true; + } + if (normalized == "dg2d") { + *out = prs::RenderMethod::DG_2D; + return true; + } + if (normalized == "dg3d") { + *out = prs::RenderMethod::DG_3D; + return true; + } + return false; +} + +void printError(const QString& message) { + QTextStream err(stderr); + err << message << '\n'; + err.flush(); +} + +} // namespace + +int main(int argc, char* argv[]) { + QCoreApplication app(argc, argv); + QCoreApplication::setApplicationName("SeicheCLI"); + QCoreApplication::setApplicationVersion("1.0.0"); + + QCommandLineParser parser; + parser.setApplicationDescription("Headless render entry point for Seiche"); + parser.addHelpOption(); + parser.addVersionOption(); + + QCommandLineOption projectOpt(QStringList() << "p" << "project", "Project file to render.", "file"); + QCommandLineOption outputOpt(QStringList() << "o" << "output", "Output directory.", "dir"); + QCommandLineOption methodOpt("method", "Render method: ray, dg2d, or dg3d.", "method"); + QCommandLineOption maxOrderOpt("max-order", "Maximum ray tracing order.", "order"); + QCommandLineOption nRaysOpt("n-rays", "Number of rays.", "count"); + QCommandLineOption scatteringOpt("scattering", "Scattering coefficient.", "value"); + QCommandLineOption airAbsorptionOpt("air-absorption", "Air absorption on or off.", "state"); + QCommandLineOption dgPolyOpt("dg-poly-order", "DG polynomial order.", "order"); + QCommandLineOption dgFreqOpt("dg-max-frequency", "DG maximum frequency.", "hz"); + QCommandLineOption sampleRateOpt("sample-rate", "Override sample rate.", "hz"); + + parser.addOption(projectOpt); + parser.addOption(outputOpt); + parser.addOption(methodOpt); + parser.addOption(maxOrderOpt); + parser.addOption(nRaysOpt); + parser.addOption(scatteringOpt); + parser.addOption(airAbsorptionOpt); + parser.addOption(dgPolyOpt); + parser.addOption(dgFreqOpt); + parser.addOption(sampleRateOpt); + + parser.process(app); + + if (!parser.isSet(projectOpt)) { + printError("Missing required --project argument."); + return 1; + } + + const QString projectPath = QFileInfo(parser.value(projectOpt)).absoluteFilePath(); + auto project = prs::ProjectFile::load(projectPath); + if (!project) { + printError(QString("Failed to load project: %1").arg(projectPath)); + return 1; + } + + prs::RenderOptions options; + if (parser.isSet(methodOpt) && !parseMethod(parser.value(methodOpt), &options.method)) { + printError("Invalid --method value. Use ray, dg2d, or dg3d."); + return 1; + } + if (parser.isSet(maxOrderOpt)) + options.maxOrder = parser.value(maxOrderOpt).toInt(); + if (parser.isSet(nRaysOpt)) + options.nRays = parser.value(nRaysOpt).toInt(); + if (parser.isSet(scatteringOpt)) + options.scattering = parser.value(scatteringOpt).toFloat(); + if (parser.isSet(airAbsorptionOpt) && !parseOnOff(parser.value(airAbsorptionOpt), &options.airAbsorption)) { + printError("Invalid --air-absorption value. Use on or off."); + return 1; + } + if (parser.isSet(dgPolyOpt)) + options.dgPolyOrder = parser.value(dgPolyOpt).toInt(); + if (parser.isSet(dgFreqOpt)) + options.dgMaxFrequency = parser.value(dgFreqOpt).toFloat(); + if (parser.isSet(sampleRateOpt)) + options.sampleRate = parser.value(sampleRateOpt).toInt(); + + const QString outputDir = + parser.isSet(outputOpt) ? QFileInfo(parser.value(outputOpt)).absoluteFilePath() : QString(); + + prs::SimulationWorker::Params params; + QString error; + if (!prs::RenderPipeline::buildSimulationParams(projectPath, *project, options, {}, outputDir, ¶ms, &error)) { + printError(error.isEmpty() ? QString("Failed to prepare render parameters for %1").arg(projectPath) : error); + return 1; + } + + prs::SimulationWorker worker(params); + QString workerError; + QObject::connect(&worker, &prs::SimulationWorker::error, [&](const QString& message) { workerError = message; }); + worker.process(); + + if (!workerError.isEmpty()) { + printError(workerError); + return 1; + } + + return 0; +} diff --git a/src/core/ProjectFile.cpp b/src/core/ProjectFile.cpp index dc4547d..655bf5c 100644 --- a/src/core/ProjectFile.cpp +++ b/src/core/ProjectFile.cpp @@ -39,6 +39,7 @@ bool ProjectFile::save(const QString& filepath, const ProjectData& data) { root["version"] = 1; root["stlFilePath"] = data.stlFilePath; root["scaleFactor"] = static_cast(data.scaleFactor); + root["sampleRate"] = data.sampleRate; root["soundSourceFile"] = data.soundSourceFile; QJsonArray colorsArr; @@ -109,6 +110,7 @@ std::optional ProjectFile::load(const QString& filepath) { data.stlFilePath = root["stlFilePath"].toString(); data.scaleFactor = static_cast(root["scaleFactor"].toDouble(1.0)); + data.sampleRate = root.contains("sampleRate") ? root["sampleRate"].toInt(DEFAULT_SAMPLE_RATE) : DEFAULT_SAMPLE_RATE; data.soundSourceFile = root["soundSourceFile"].toString(); for (auto val : root["surfaceColors"].toArray()) diff --git a/src/core/ProjectFile.h b/src/core/ProjectFile.h index 76af0c0..19f5206 100644 --- a/src/core/ProjectFile.h +++ b/src/core/ProjectFile.h @@ -14,6 +14,7 @@ namespace prs { struct ProjectData { QString stlFilePath; float scaleFactor = 1.0f; + int sampleRate = DEFAULT_SAMPLE_RATE; std::vector surfaceColors; std::vector> surfaceMaterials; std::vector placedPoints; diff --git a/src/rendering/TextureManager.cpp b/src/rendering/TextureManager.cpp index 5b5ced6..64ca7d0 100644 --- a/src/rendering/TextureManager.cpp +++ b/src/rendering/TextureManager.cpp @@ -17,7 +17,7 @@ GLuint TextureManager::getOrLoad(const QString& path) { QImage img(path); if (img.isNull()) return 0; - QImage glImg = img.convertToFormat(QImage::Format_RGBA8888).mirrored(false, true); + QImage glImg = img.convertToFormat(QImage::Format_RGBA8888).flipped(Qt::Vertical); GLuint texId = 0; glGenTextures(1, &texId); diff --git a/test_files.zip b/test_files.zip new file mode 100644 index 0000000..0a2efe3 Binary files /dev/null and b/test_files.zip differ diff --git a/test_files/Beveled_snub_dodecahedron.stl b/test_files/Beveled_snub_dodecahedron.stl new file mode 100644 index 0000000..6ad9b9a Binary files /dev/null and b/test_files/Beveled_snub_dodecahedron.stl differ diff --git a/test_files/Cube_rounded.stl b/test_files/Cube_rounded.stl new file mode 100644 index 0000000..5a73d9f Binary files /dev/null and b/test_files/Cube_rounded.stl differ diff --git a/test_files/Cylinder.stl b/test_files/Cylinder.stl new file mode 100644 index 0000000..f1332e7 Binary files /dev/null and b/test_files/Cylinder.stl differ diff --git a/test_files/Dodecahedron.stl b/test_files/Dodecahedron.stl new file mode 100644 index 0000000..1a3f9f6 Binary files /dev/null and b/test_files/Dodecahedron.stl differ diff --git a/test_files/Dodecahexacontakaihexahedron.stl b/test_files/Dodecahexacontakaihexahedron.stl new file mode 100644 index 0000000..e508302 Binary files /dev/null and b/test_files/Dodecahexacontakaihexahedron.stl differ diff --git a/test_files/Prism_triangle.stl b/test_files/Prism_triangle.stl new file mode 100644 index 0000000..6c39f90 Binary files /dev/null and b/test_files/Prism_triangle.stl differ diff --git a/test_files/Pyramid_square.stl b/test_files/Pyramid_square.stl new file mode 100644 index 0000000..401f39d Binary files /dev/null and b/test_files/Pyramid_square.stl differ diff --git a/test_files/Room1.STL b/test_files/Room1.STL new file mode 100644 index 0000000..7b681dd Binary files /dev/null and b/test_files/Room1.STL differ diff --git a/test_files/Room2.STL b/test_files/Room2.STL new file mode 100644 index 0000000..003e8db Binary files /dev/null and b/test_files/Room2.STL differ diff --git a/test_files/Room3.STL b/test_files/Room3.STL new file mode 100644 index 0000000..2ec0933 Binary files /dev/null and b/test_files/Room3.STL differ diff --git a/test_files/Room4.STL b/test_files/Room4.STL new file mode 100644 index 0000000..e7c3259 Binary files /dev/null and b/test_files/Room4.STL differ diff --git a/test_files/Room5.STL b/test_files/Room5.STL new file mode 100644 index 0000000..c94b5da Binary files /dev/null and b/test_files/Room5.STL differ diff --git a/test_files/Semi-Sphere.stl b/test_files/Semi-Sphere.stl new file mode 100644 index 0000000..4bffceb Binary files /dev/null and b/test_files/Semi-Sphere.stl differ diff --git a/test_files/Tetrahemihexacron_3D_model.stl b/test_files/Tetrahemihexacron_3D_model.stl new file mode 100644 index 0000000..876be9f Binary files /dev/null and b/test_files/Tetrahemihexacron_3D_model.stl differ diff --git a/test_files/cube_hollow.stl b/test_files/cube_hollow.stl new file mode 100644 index 0000000..12ec23a Binary files /dev/null and b/test_files/cube_hollow.stl differ diff --git a/test_files/example.room b/test_files/example.room new file mode 100644 index 0000000..233ab08 --- /dev/null +++ b/test_files/example.room @@ -0,0 +1,162 @@ +{ + "placedPoints": [ + { + "audioFile": "", + "color": [ + 0.20000000298023224, + 0.800000011920929, + 0.20000000298023224 + ], + "distance": -1.0000001192092896, + "name": "speaker 1", + "normal": { + "x": -0.7726957201957703, + "y": 0.6347765922546387, + "z": 0 + }, + "orientationYaw": 0, + "pointType": "source", + "surfacePoint": { + "x": 0.9413361549377441, + "y": 3.884629011154175, + "z": 2.5850305557250977 + }, + "volume": 1 + }, + { + "audioFile": "", + "color": [ + 0.800000011920929, + 0.20000000298023224, + 0.20000000298023224 + ], + "distance": -1.0000001192092896, + "name": "speaker 2", + "normal": { + "x": 0.6864100098609924, + "y": -0.7272147536277771, + "z": 0 + }, + "orientationYaw": 0, + "pointType": "source", + "surfacePoint": { + "x": 7.443802356719971, + "y": 2.071918249130249, + "z": 2.449075698852539 + }, + "volume": 1 + }, + { + "audioFile": "", + "color": [ + 0.20000000298023224, + 0.20000000298023224, + 0.800000011920929 + ], + "distance": -0.5, + "name": "first", + "normal": { + "x": 0, + "y": 0, + "z": -1 + }, + "orientationYaw": 94, + "pointType": "listener", + "surfacePoint": { + "x": 3.5920722484588623, + "y": 2.1295676231384277, + "z": 9.5367431640625e-07 + }, + "volume": 1 + }, + { + "audioFile": "", + "color": [ + 0.800000011920929, + 0.800000011920929, + 0.20000000298023224 + ], + "distance": -0.5, + "name": "second", + "normal": { + "x": 0, + "y": 0, + "z": -1 + }, + "orientationYaw": 214, + "pointType": "listener", + "surfacePoint": { + "x": 5.611714839935303, + "y": 4.261205196380615, + "z": 9.5367431640625e-07 + }, + "volume": 1 + }, + { + "audioFile": "", + "color": [ + 0.800000011920929, + 0.20000000298023224, + 0.800000011920929 + ], + "distance": -0.6000000238418579, + "name": "third", + "normal": { + "x": 0, + "y": 0, + "z": -1 + }, + "orientationYaw": 269, + "pointType": "listener", + "surfacePoint": { + "x": 3.761333703994751, + "y": 6.006850719451904, + "z": 9.5367431640625e-07 + }, + "volume": 1 + } + ], + "scaleFactor": 1.100000023841858, + "soundSourceFile": "", + "stlFilePath": "C:/Users/Ematt/Documents/Seiche/Seiche/test_files/Room1.STL", + "surfaceColors": [ + [ + 0.7592995762825012, + 0.6870308518409729, + 0.5859730243682861 + ], + [ + 0.7592995762825012, + 0.6870308518409729, + 0.5859730243682861 + ], + [ + 0.40982577204704285, + 0.11392093449831009, + 0.06772459298372269 + ], + [ + 0.40982577204704285, + 0.11392093449831009, + 0.06772459298372269 + ], + [ + 0.6661169528961182, + 0.5989418029785156, + 0.010397803038358688 + ], + [ + 0.5234431624412537, + 0.7226724624633789, + 0.835527777671814 + ], + [ + 0.15728072822093964, + 0.10114516317844391, + 0.2271365523338318 + ] + ], + "surfaceMaterials": [ + ], + "version": 1 +} diff --git a/test_files/prism_star_5.stl b/test_files/prism_star_5.stl new file mode 100644 index 0000000..60626cf Binary files /dev/null and b/test_files/prism_star_5.stl differ diff --git a/test_files/pyramid_pentagon.stl b/test_files/pyramid_pentagon.stl new file mode 100644 index 0000000..4dbd5bb Binary files /dev/null and b/test_files/pyramid_pentagon.stl differ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fa5158c..56f7f1c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -75,6 +75,7 @@ set(GUI_TEST_EXTRA ${CMAKE_SOURCE_DIR}/src/gui/widgets/ColorSwatch.cpp ${CMAKE_SOURCE_DIR}/src/utils/ResourcePath.cpp ${CMAKE_SOURCE_DIR}/src/acoustics/SimulationWorker.cpp + ${CMAKE_SOURCE_DIR}/src/acoustics/RenderExports.cpp ${CMAKE_SOURCE_DIR}/src/acoustics/SimulationQueue.cpp ${CMAKE_SOURCE_DIR}/src/acoustics/dg/DGBasis2D.cpp ${CMAKE_SOURCE_DIR}/src/acoustics/dg/DGBasis3D.cpp @@ -108,3 +109,40 @@ endif() target_compile_definitions(test_gui PRIVATE _USE_MATH_DEFINES) set_target_properties(test_gui PROPERTIES AUTOMOC ON AUTORCC ON) add_test(NAME test_gui COMMAND test_gui) + +# CLI render integration test +set(RENDER_CLI_TEST_EXTRA + ${CMAKE_SOURCE_DIR}/src/acoustics/RenderExports.cpp + ${CMAKE_SOURCE_DIR}/src/acoustics/RenderPipeline.cpp + ${CMAKE_SOURCE_DIR}/src/acoustics/SimulationWorker.cpp + ${CMAKE_SOURCE_DIR}/src/acoustics/SimulationQueue.cpp + ${CMAKE_SOURCE_DIR}/src/acoustics/dg/DGBasis2D.cpp + ${CMAKE_SOURCE_DIR}/src/acoustics/dg/DGBasis3D.cpp + ${CMAKE_SOURCE_DIR}/src/acoustics/dg/DGMesh2D.cpp + ${CMAKE_SOURCE_DIR}/src/acoustics/dg/DGMesh3D.cpp + ${CMAKE_SOURCE_DIR}/src/acoustics/dg/DGAcoustics2D.cpp + ${CMAKE_SOURCE_DIR}/src/acoustics/dg/DGAcoustics3D.cpp + ${CMAKE_SOURCE_DIR}/src/acoustics/dg/DGSolver.cpp + ${CMAKE_SOURCE_DIR}/src/acoustics/dg/DGGpuCompute.cpp + ${CMAKE_SOURCE_DIR}/src/utils/ResourcePath.cpp + ${CMAKE_SOURCE_DIR}/src/rendering/Viewport3D.cpp +) +add_executable(test_render_cli test_render_cli.cpp ${TEST_COMMON_SOURCES} ${RENDER_CLI_TEST_EXTRA}) +target_include_directories(test_render_cli PRIVATE ${TEST_INCLUDE_DIRS}) +target_link_libraries(test_render_cli PRIVATE + Qt6::Test Qt6::Widgets Qt6::OpenGL Qt6::OpenGLWidgets Qt6::Multimedia Qt6::Svg Eigen3::Eigen) +if(OpenMP_CXX_FOUND) + target_link_libraries(test_render_cli PRIVATE OpenMP::OpenMP_CXX) +endif() +if(WIN32) + target_link_libraries(test_render_cli PRIVATE opengl32 glu32) +elseif(APPLE) + find_package(OpenGL REQUIRED) + target_link_libraries(test_render_cli PRIVATE OpenGL::GL OpenGL::GLU) +else() + find_package(OpenGL REQUIRED) + target_link_libraries(test_render_cli PRIVATE OpenGL::GL OpenGL::GLU) +endif() +target_compile_definitions(test_render_cli PRIVATE _USE_MATH_DEFINES) +set_target_properties(test_render_cli PROPERTIES AUTOMOC ON) +add_test(NAME test_render_cli COMMAND test_render_cli) diff --git a/tests/test_gui.cpp b/tests/test_gui.cpp index 9e7d124..0764924 100644 --- a/tests/test_gui.cpp +++ b/tests/test_gui.cpp @@ -21,7 +21,7 @@ class TestGUI : public QObject { private slots: void testMainWindowCreation() { MainWindow w; - QVERIFY(w.windowTitle().contains("PyRoomStudio")); + QVERIFY(w.windowTitle().contains("Seiche")); QVERIFY(w.viewport() != nullptr); QVERIFY(w.propertyPanel() != nullptr); QVERIFY(w.libraryPanel() != nullptr); @@ -108,7 +108,7 @@ private slots: void testWindowTitleUpdates() { MainWindow w; - QVERIFY(w.windowTitle() == "PyRoomStudio"); + QVERIFY(w.windowTitle() == "Seiche"); } // ==================== New Feature Tests ==================== diff --git a/tests/test_render_cli.cpp b/tests/test_render_cli.cpp new file mode 100644 index 0000000..ae8e3aa --- /dev/null +++ b/tests/test_render_cli.cpp @@ -0,0 +1,354 @@ +#include "audio/AudioFile.h" +#include "core/PlacedPoint.h" +#include "core/ProjectFile.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace prs; + +namespace { + +bool writeTextFile(const QString& path, const QByteArray& contents) { + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return false; + return file.write(contents) == contents.size(); +} + +QString cliExecutablePath() { +#ifdef Q_OS_WIN + const QString name = "SeicheCLI.exe"; +#else + const QString name = "SeicheCLI"; +#endif + const QDir appDir(QCoreApplication::applicationDirPath()); + const QString local = appDir.filePath(name); + if (QFileInfo::exists(local)) + return local; + return appDir.filePath(QStringLiteral("../%1").arg(name)); +} + +void writeSquarePlaneObj(const QString& path) { + const QByteArray obj = R"(v 0 0 0 +v 1 0 0 +v 1 1 0 +v 0 1 0 +f 1 2 3 +f 1 3 4 +)"; + QVERIFY(writeTextFile(path, obj)); +} + +void writeFixtureAudio(const QString& path) { + AudioFile af; + af.samples() = {0.0f, 0.25f, 0.5f, 0.25f, 0.0f, -0.25f, -0.5f, -0.25f}; + QVERIFY(af.save(path, 22050)); +} + +ProjectData makeProject() { + ProjectData data; + data.stlFilePath = "model.obj"; + data.scaleFactor = 1.0f; + data.sampleRate = 22050; + data.soundSourceFile = "source.wav"; + data.surfaceColors = {{0.2f, 0.4f, 0.6f}}; + data.surfaceMaterials = {std::nullopt}; + + PlacedPoint source; + source.surfacePoint = Vec3f(0.25f, 0.25f, 0.0f); + source.normal = Vec3f(0.0f, 0.0f, 1.0f); + source.distance = 0.1f; + source.pointType = POINT_TYPE_SOURCE; + source.name = "Source"; + source.volume = 1.0f; + source.audioFile.clear(); + data.placedPoints.push_back(source); + + PlacedPoint listener; + listener.surfacePoint = Vec3f(0.75f, 0.75f, 0.0f); + listener.normal = Vec3f(0.0f, 0.0f, 1.0f); + listener.distance = 0.1f; + listener.pointType = POINT_TYPE_LISTENER; + listener.name = "Listener"; + listener.orientationYaw = 0.0f; + data.placedPoints.push_back(listener); + + return data; +} + +} // namespace + +class TestRenderCli : public QObject { + Q_OBJECT + private slots: + + void initTestCase() { + cliPath_ = cliExecutablePath(); + QVERIFY2(QFileInfo::exists(cliPath_), qPrintable(QString("Missing CLI executable: %1").arg(cliPath_))); + } + + // -- Error-handling tests ------------------------------------------------ + + void testNoArguments() { + QProcess proc; + proc.setProgram(cliPath_); + proc.start(); + QVERIFY(proc.waitForFinished(5000)); + QCOMPARE(proc.exitCode(), 1); + QVERIFY(proc.readAllStandardError().contains("--project")); + } + + void testMissingProjectFile() { + QProcess proc; + proc.setProgram(cliPath_); + proc.setArguments({"--project", "/nonexistent/path/does_not_exist.room"}); + proc.start(); + QVERIFY(proc.waitForFinished(5000)); + QCOMPARE(proc.exitCode(), 1); + QVERIFY(proc.readAllStandardError().contains("Failed to load")); + } + + void testInvalidMethod() { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString projectDir = tempDir.path(); + writeSquarePlaneObj(QDir(projectDir).filePath("model.obj")); + writeFixtureAudio(QDir(projectDir).filePath("source.wav")); + QVERIFY(ProjectFile::save(QDir(projectDir).filePath("test.room"), makeProject())); + + QProcess proc; + proc.setProgram(cliPath_); + proc.setArguments({"--project", QDir(projectDir).filePath("test.room"), "--method", "bogus"}); + proc.start(); + QVERIFY(proc.waitForFinished(5000)); + QCOMPARE(proc.exitCode(), 1); + QVERIFY(proc.readAllStandardError().contains("--method")); + } + + void testInvalidAirAbsorption() { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString projectDir = tempDir.path(); + writeSquarePlaneObj(QDir(projectDir).filePath("model.obj")); + writeFixtureAudio(QDir(projectDir).filePath("source.wav")); + QVERIFY(ProjectFile::save(QDir(projectDir).filePath("test.room"), makeProject())); + + QProcess proc; + proc.setProgram(cliPath_); + proc.setArguments({"--project", QDir(projectDir).filePath("test.room"), "--air-absorption", "maybe"}); + proc.start(); + QVERIFY(proc.waitForFinished(5000)); + QCOMPARE(proc.exitCode(), 1); + QVERIFY(proc.readAllStandardError().contains("--air-absorption")); + } + + void testCorruptProjectFile() { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + QVERIFY(writeTextFile(QDir(tempDir.path()).filePath("bad.room"), "{ not valid json !!!")); + + QProcess proc; + proc.setProgram(cliPath_); + proc.setArguments({"--project", QDir(tempDir.path()).filePath("bad.room")}); + proc.start(); + QVERIFY(proc.waitForFinished(5000)); + QCOMPARE(proc.exitCode(), 1); + } + + void testProjectWithNoMesh() { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + ProjectData data; + data.scaleFactor = 1.0f; + QVERIFY(ProjectFile::save(QDir(tempDir.path()).filePath("empty.room"), data)); + + QProcess proc; + proc.setProgram(cliPath_); + proc.setArguments({"--project", QDir(tempDir.path()).filePath("empty.room")}); + proc.start(); + QVERIFY(proc.waitForFinished(5000)); + QCOMPARE(proc.exitCode(), 1); + } + + // -- Happy-path tests ---------------------------------------------------- + + void testCliRendersFixtureProject() { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString projectDir = tempDir.path(); + writeSquarePlaneObj(QDir(projectDir).filePath("model.obj")); + writeFixtureAudio(QDir(projectDir).filePath("source.wav")); + + ProjectData project = makeProject(); + QVERIFY(ProjectFile::save(QDir(projectDir).filePath("fixture.room"), project)); + + const QString outputDir = QDir(projectDir).filePath("out"); + QProcess proc; + proc.setProgram(cliPath_); + proc.setArguments({"--project", QDir(projectDir).filePath("fixture.room"), "--output", outputDir, "--method", + "ray", "--max-order", "1", "--n-rays", "128", "--scattering", "0.1", "--air-absorption", + "off", "--sample-rate", "22050"}); + proc.setWorkingDirectory(projectDir); + proc.start(); + QVERIFY2(proc.waitForStarted(), qPrintable(proc.errorString())); + QVERIFY2(proc.waitForFinished(120000), "CLI render timed out"); + QCOMPARE(proc.exitStatus(), QProcess::NormalExit); + QCOMPARE(proc.exitCode(), 0); + + QFileInfo outInfo(outputDir); + QVERIFY(outInfo.exists()); + QVERIFY(outInfo.isDir()); + + QDir outDir(outputDir); + QVERIFY(outDir.exists("scene.json")); + QVERIFY(outDir.exists("metrics.json")); + QVERIFY(outDir.exists("metrics.csv")); + QVERIFY(outDir.exists("summary.json")); + + const QStringList wavs = outDir.entryList(QStringList() << "*.wav", QDir::Files); + QVERIFY2(!wavs.isEmpty(), "Expected at least one WAV output"); + + QFile metricsFile(outDir.filePath("metrics.json")); + QVERIFY(metricsFile.open(QIODevice::ReadOnly)); + const QJsonDocument metricsDoc = QJsonDocument::fromJson(metricsFile.readAll()); + metricsFile.close(); + QVERIFY(metricsDoc.isObject()); + QCOMPARE(metricsDoc.object().value("sample_rate").toInt(), 22050); + QVERIFY(metricsDoc.object().value("pairs").isArray()); + QCOMPARE(metricsDoc.object().value("pairs").toArray().size(), 1); + + QFile summaryFile(outDir.filePath("summary.json")); + QVERIFY(summaryFile.open(QIODevice::ReadOnly)); + const QJsonDocument summaryDoc = QJsonDocument::fromJson(summaryFile.readAll()); + summaryFile.close(); + QVERIFY(summaryDoc.isObject()); + QCOMPARE(summaryDoc.object().value("sample_rate").toInt(), 22050); + + QFile csvFile(outDir.filePath("metrics.csv")); + QVERIFY(csvFile.open(QIODevice::ReadOnly | QIODevice::Text)); + const QByteArray csvContents = csvFile.readAll(); + csvFile.close(); + QVERIFY(csvContents.startsWith("source,listener")); + } + + void testVersionFlag() { + QProcess proc; + proc.setProgram(cliPath_); + proc.setArguments({"--version"}); + proc.start(); + QVERIFY(proc.waitForFinished(5000)); + QCOMPARE(proc.exitCode(), 0); + const QByteArray output = proc.readAllStandardOutput(); + QVERIFY2(output.contains("1.0.0"), qPrintable(QString("Unexpected version output: %1").arg(QString(output)))); + } + + void testSampleRateOverride() { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString projectDir = tempDir.path(); + writeSquarePlaneObj(QDir(projectDir).filePath("model.obj")); + writeFixtureAudio(QDir(projectDir).filePath("source.wav")); + QVERIFY(ProjectFile::save(QDir(projectDir).filePath("fixture.room"), makeProject())); + + const QString outputDir = QDir(projectDir).filePath("out"); + QProcess proc; + proc.setProgram(cliPath_); + proc.setArguments({"--project", QDir(projectDir).filePath("fixture.room"), "--output", outputDir, + "--max-order", "1", "--n-rays", "64", "--sample-rate", "16000"}); + proc.setWorkingDirectory(projectDir); + proc.start(); + QVERIFY(proc.waitForStarted()); + QVERIFY2(proc.waitForFinished(120000), "CLI render timed out"); + QCOMPARE(proc.exitCode(), 0); + + QFile metricsFile(QDir(outputDir).filePath("metrics.json")); + QVERIFY(metricsFile.open(QIODevice::ReadOnly)); + const QJsonDocument doc = QJsonDocument::fromJson(metricsFile.readAll()); + metricsFile.close(); + QCOMPARE(doc.object().value("sample_rate").toInt(), 16000); + } + + void testMetricsVersionConsistency() { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString projectDir = tempDir.path(); + writeSquarePlaneObj(QDir(projectDir).filePath("model.obj")); + writeFixtureAudio(QDir(projectDir).filePath("source.wav")); + QVERIFY(ProjectFile::save(QDir(projectDir).filePath("fixture.room"), makeProject())); + + const QString outputDir = QDir(projectDir).filePath("out"); + QProcess proc; + proc.setProgram(cliPath_); + proc.setArguments({"--project", QDir(projectDir).filePath("fixture.room"), "--output", outputDir, + "--max-order", "1", "--n-rays", "64"}); + proc.setWorkingDirectory(projectDir); + proc.start(); + QVERIFY(proc.waitForStarted()); + QVERIFY2(proc.waitForFinished(120000), "CLI render timed out"); + QCOMPARE(proc.exitCode(), 0); + + QFile metricsFile(QDir(outputDir).filePath("metrics.json")); + QVERIFY(metricsFile.open(QIODevice::ReadOnly)); + const QJsonDocument metricsDoc = QJsonDocument::fromJson(metricsFile.readAll()); + metricsFile.close(); + const QString metricsVersion = metricsDoc.object().value("version").toString(); + + QFile summaryFile(QDir(outputDir).filePath("summary.json")); + QVERIFY(summaryFile.open(QIODevice::ReadOnly)); + const QJsonDocument summaryDoc = QJsonDocument::fromJson(summaryFile.readAll()); + summaryFile.close(); + const QString summaryVersion = summaryDoc.object().value("version").toString(); + + QCOMPARE(metricsVersion, summaryVersion); + } + + void testCsvRowCount() { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString projectDir = tempDir.path(); + writeSquarePlaneObj(QDir(projectDir).filePath("model.obj")); + writeFixtureAudio(QDir(projectDir).filePath("source.wav")); + QVERIFY(ProjectFile::save(QDir(projectDir).filePath("fixture.room"), makeProject())); + + const QString outputDir = QDir(projectDir).filePath("out"); + QProcess proc; + proc.setProgram(cliPath_); + proc.setArguments({"--project", QDir(projectDir).filePath("fixture.room"), "--output", outputDir, + "--max-order", "1", "--n-rays", "64"}); + proc.setWorkingDirectory(projectDir); + proc.start(); + QVERIFY(proc.waitForStarted()); + QVERIFY2(proc.waitForFinished(120000), "CLI render timed out"); + QCOMPARE(proc.exitCode(), 0); + + QFile csvFile(QDir(outputDir).filePath("metrics.csv")); + QVERIFY(csvFile.open(QIODevice::ReadOnly | QIODevice::Text)); + const QList lines = csvFile.readAll().split('\n'); + csvFile.close(); + + QVERIFY(lines.at(0).startsWith("source,listener")); + int dataRows = 0; + for (int i = 1; i < lines.size(); ++i) + if (!lines.at(i).trimmed().isEmpty()) + ++dataRows; + QCOMPARE(dataRows, 1); + } + + private: + QString cliPath_; +}; + +QTEST_MAIN(TestRenderCli) +#include "test_render_cli.moc"