diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 16a5e98a..2cf13449 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,6 +31,7 @@ jobs: export PATH=$(echo $PATH | tr ':' '\n' | grep -v "Git/bin" | tr '\n' ':') cmake -S . -B build -G "MinGW Makefiles" \ -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_BENCHMARKS=ON \ -DCMAKE_POLICY_VERSION_MINIMUM=3.5 cmake --build build -j 4 @@ -38,5 +39,32 @@ jobs: if: runner.os != 'Windows' run: | cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_BENCHMARKS=ON \ -DCMAKE_POLICY_VERSION_MINIMUM=3.5 cmake --build build -j 4 + + - name: Run CTest + shell: bash + run: | + # Исправление бага: раньше CI только собирал проект. Inventory guard + # явно падает, если regression-target отсутствует, вместо тихого pass. + ctest --test-dir build -N | tee ctest_inventory.txt + if grep -q "Total Tests: 0" ctest_inventory.txt; then + echo "CTest registered zero tests" + exit 1 + fi + ctest --test-dir build --output-on-failure + + - name: Benchmark Smoke + shell: bash + run: | + # Исправление бага: benchmark-регрессии теперь ловятся inventory-check + # и одним коротким smoke-case вместо полного медленного набора. + if [ "$RUNNER_OS" = "Windows" ]; then + BENCHMARK_EXE="build/benchmarks.exe" + else + BENCHMARK_EXE="build/benchmarks" + fi + "$BENCHMARK_EXE" --benchmark_list_tests=true | tee benchmark_inventory.txt + grep -q "^SimulationFixture/Correct/125$" benchmark_inventory.txt + "$BENCHMARK_EXE" --benchmark_filter="^SimulationFixture/Correct/125$" --benchmark_min_time=0.001s --benchmark_repetitions=1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cd81e33..46ddd943 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,22 +33,23 @@ jobs: -DCMAKE_EXE_LINKER_FLAGS="-static -static-libgcc -static-libstdc++" cmake --build build --config Release -j 4 + # Исправление бага: упаковка release раньше вручную копировала выбранные + # файлы и могла пропустить wgpu_native.dll. Теперь ставим Runtime component. + cmake --install build --prefix release_dist --component Runtime - name: Check Dependencies (Verification) shell: bash run: | - objdump -p LatticeLab.exe | grep "DLL Name" + # Исправление бага: проверяем именно те runtime-файлы, которые нужны zip. + test -f release_dist/LatticeLab.exe + test -f release_dist/wgpu_native.dll + objdump -p release_dist/LatticeLab.exe | grep "DLL Name" - name: Prepare Release Artifacts shell: bash run: | - mkdir -p release_dist/assets - mkdir -p release_dist/demo - cp LatticeLab.exe release_dist/ - cp -r assets/* release_dist/assets/ - echo "Contents of demo:" - ls -R demo || true - cp -r demo/scenes release_dist/demo/ + test -d release_dist/assets + test -d release_dist/demo/scenes cd release_dist 7z a ../LatticeLab-${{ github.event.inputs.version }}-win64.zip * diff --git a/App/save_system/AppStateIO.cpp b/App/save_system/AppStateIO.cpp index a4a0f0d8..5f81309c 100644 --- a/App/save_system/AppStateIO.cpp +++ b/App/save_system/AppStateIO.cpp @@ -49,6 +49,15 @@ namespace { return std::string(value.substr(begin, end - begin)); } + bool hasConsistentAtomArrays(const SimulationSaveState& state) { + const size_t atomCount = state.x.size(); + // Исправление бага: binary loads раньше доверяли atom arrays до + // AtomStorage::init. Inconsistent saves отклоняются до mutation state. + return state.atomMobileCount <= atomCount && state.y.size() == atomCount && state.z.size() == atomCount && + state.vx.size() == atomCount && state.vy.size() == atomCount && state.vz.size() == atomCount && + state.atomType.size() == atomCount && state.atomCharge.size() == atomCount; + } + std::string encodeBase64(std::span data) { static constexpr char kAlphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; @@ -408,15 +417,19 @@ void AppStateIO::loadBinary(Simulation& simulation, IRenderer& renderer, std::st return; } - // Заголовок const auto& header = appState.header; - simulation.setWorldTitle(header.title); - simulation.setWorldDescription(header.description); - // Симуляция const auto& simState = appState.simulation; + if (!hasConsistentAtomArrays(simState)) { + std::cerr << "Invalid atom arrays in save file" << std::endl; + return; + } simulation.clear(); + // Исправление бага: title/description раньше задавались до clear(), который + // сразу их стирал. Metadata назначается после очистки старой scene. + simulation.setWorldTitle(header.title); + simulation.setWorldDescription(header.description); simulation.setSizeBox(simState.boxSize, simState.gridCellSize); simulation.setNeighborListCutoff(simState.neighborListCutoff); @@ -435,7 +448,7 @@ void AppStateIO::loadBinary(Simulation& simulation, IRenderer& renderer, std::st const uint64_t atomCount = simState.x.size(); AtomStorage& atoms = simulation.atoms(); - simulation.reserveAtoms(atoms.size()); + simulation.reserveAtoms(atomCount); atoms.init(atomCount, atomMobileCount, simState.x, simState.y, simState.z, simState.vx, simState.vy, simState.vz, simState.atomType, simState.atomCharge); simulation.finalizeAtomBatch(); diff --git a/Benchmarks/BenchmarkScenes.cpp b/Benchmarks/BenchmarkScenes.cpp index b033e610..bfd0b624 100644 --- a/Benchmarks/BenchmarkScenes.cpp +++ b/Benchmarks/BenchmarkScenes.cpp @@ -17,8 +17,10 @@ namespace Benchmarks { switch (benchmarkCase.scene) { case SceneKind::IdealCrystal3D: - buildCrystal2D(simulation, benchmarkCase); - break; + // Исправление бага: этот case раньше вызывал 2D builder, поэтому + // имя benchmark не соответствовало измеряемой сцене. + buildIdealCrystal3D(simulation, benchmarkCase); + break; case SceneKind::Crystal2D: buildCrystal2D(simulation, benchmarkCase); break; diff --git a/Benchmarks/CMakeLists.txt b/Benchmarks/CMakeLists.txt index be3c8dad..3fbabfc2 100644 --- a/Benchmarks/CMakeLists.txt +++ b/Benchmarks/CMakeLists.txt @@ -23,3 +23,9 @@ target_link_libraries(benchmarks PRIVATE benchmark::benchmark_main latticelab_lib ) + +# Исправление бага: benchmarks нужен тот же WebGPU runtime DLL рядом с executable, +# что и приложению; иначе renderer benchmark smoke может упасть вне корня исходников. +if(COMMAND target_copy_webgpu_binaries) + target_copy_webgpu_binaries(benchmarks) +endif() diff --git a/Benchmarks/fixtures/SimulationFixture.h b/Benchmarks/fixtures/SimulationFixture.h index 4b4a2364..5437cf47 100644 --- a/Benchmarks/fixtures/SimulationFixture.h +++ b/Benchmarks/fixtures/SimulationFixture.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -65,6 +66,9 @@ class SimulationFixture : public benchmark::Fixture { .world = simulation_->world(), .forceField = simulation_->forceField(), .neighborList = simulation_->neighborList(), + // Исправление бага: передаём simStep, чтобы stats rebuild NeighborList + // оставались meaningful после force-entry refresh. + .simStep = simulation_->getSimStep(), .allowBondFormation = simulation_->isBondFormationEnabled(), .accelDamping = accelDamping, .dt = static_cast(Benchmarks::kDt), @@ -82,10 +86,23 @@ class SimulationFixture : public benchmark::Fixture { void prepareNeighborList() { simulation_->neighborList().build(simulation_->atoms(), simulation_->world()); } + void clearForcesAndEnergy() { + // Исправление бага: force benchmarks раньше накапливали force/energy + // между iterations, измеряя corrupted state вместо одного clean force pass. + AtomStorage& atoms = simulation_->atoms(); + std::fill_n(atoms.fxData(), atoms.size(), 0.0f); + std::fill_n(atoms.fyData(), atoms.size(), 0.0f); + std::fill_n(atoms.fzData(), atoms.size(), 0.0f); + std::fill_n(atoms.energyData(), atoms.size(), 0.0f); + } + void prepareForCorrect() { prepareForPredict(); StepData stepData = makeStepData(); StepOps::predictAndSync(stepData, &VerletScheme::predict); + // Исправление бага: benchmark fixtures готовятся с тем же post-predict + // refresh NeighborList, который ожидают реальные force paths integrator. + StepOps::refreshNeighborListIfNeeded(stepData); StepOps::computeForces(stepData); } diff --git a/Benchmarks/physics/BM_ComputeForces.cpp b/Benchmarks/physics/BM_ComputeForces.cpp index 5d9c0bb0..6edec007 100644 --- a/Benchmarks/physics/BM_ComputeForces.cpp +++ b/Benchmarks/physics/BM_ComputeForces.cpp @@ -9,6 +9,9 @@ BENCHMARK_DEFINE_F(SimulationFixture, ComputeForcesWithNeighborList)(benchmark:: StepData stepData = makeStepData(); for (auto _ : state) { + // Исправление бага: каждая итерация начинается с чистых accumulators, + // чтобы benchmark измерял один force pass вместо накопленного state. + clearForcesAndEnergy(); StepOps::computeForces(stepData); benchmark::DoNotOptimize(simulation_->atoms().size()); benchmark::ClobberMemory(); @@ -22,6 +25,9 @@ BENCHMARK_DEFINE_F(SimulationFixture, ComputePairInteractionsWithNeighborList)(b prepareNeighborList(); for (auto _ : state) { + // Исправление бага: pair-interaction-only timing нужны такие же чистые + // buffers force и energy, как full force benchmark. + clearForcesAndEnergy(); simulation_->forceField().computePairInteractions(simulation_->world()); benchmark::DoNotOptimize(simulation_->atoms().size()); benchmark::ClobberMemory(); diff --git a/Benchmarks/physics/BM_NeighborNeedListRebuild.cpp b/Benchmarks/physics/BM_NeighborNeedListRebuild.cpp index 15abfee6..72ba24d8 100644 --- a/Benchmarks/physics/BM_NeighborNeedListRebuild.cpp +++ b/Benchmarks/physics/BM_NeighborNeedListRebuild.cpp @@ -6,10 +6,13 @@ // соседи"} BENCHMARK_DEFINE_F(SimulationFixture, NeighborListNeedRebuild)(benchmark::State& state) { rebuildScene(); + // Исправление бага: этот benchmark раньше измерял invalid fast path. Сначала + // строим valid list, чтобы timed loop измерял scan displacement. + prepareNeighborList(); for (auto _ : state) { - simulation_->neighborList().needsRebuild(simulation_->atoms()); - benchmark::DoNotOptimize(simulation_->neighborList().pairStorageSize()); + const bool needsRebuild = simulation_->neighborList().needsRebuild(simulation_->atoms()); + benchmark::DoNotOptimize(needsRebuild); benchmark::ClobberMemory(); } diff --git a/CMakeLists.txt b/CMakeLists.txt index c54c8177..3ff8222d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,12 +12,17 @@ option(OPTIMIZE_FOR_NATIVE "Enable native CPU optimizations outside Debug builds option(BUILD_BENCHMARKS "Build benchmarks" OFF) option(ENABLE_IPO "Enable link-time optimization for non-Debug builds when supported" ON) +include(CTest) + set(CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") +# Исправление бага: runtime-exe раньше записывались в корень исходников. +# Теперь они остаются внутри дерева сборки, чтобы артефакты debug/release/bench +# не перезаписывали друг друга и не загрязняли checkout. +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}") set(FETCHCONTENT_BASE_DIR "${CMAKE_BINARY_DIR}/_deps" CACHE PATH "FetchContent base directory" FORCE) # CMake 4.x: allow legacy dependencies that declare very old minimum policies. @@ -149,10 +154,23 @@ target_copy_webgpu_binaries(${APP_NAME}) include(windows_resources) -install(TARGETS ${APP_NAME} RUNTIME DESTINATION .) -install(DIRECTORY "${CMAKE_SOURCE_DIR}/assets" DESTINATION .) +# Исправление бага: упаковка release раньше вручную копировала файлы и могла +# пропустить wgpu_native.dll. Компонент Runtime теперь является единым источником +# для exe, WebGPU runtime DLL, assets и demo scenes. +install(TARGETS ${APP_NAME} RUNTIME DESTINATION . COMPONENT Runtime) +if(WEBGPU_RUNTIME_LIB) + install(FILES "${WEBGPU_RUNTIME_LIB}" DESTINATION . COMPONENT Runtime) +endif() +install(DIRECTORY "${CMAKE_SOURCE_DIR}/assets" DESTINATION . COMPONENT Runtime) if(EXISTS "${CMAKE_SOURCE_DIR}/demo/scenes") - install(DIRECTORY "${CMAKE_SOURCE_DIR}/demo/scenes" DESTINATION demo) + install(DIRECTORY "${CMAKE_SOURCE_DIR}/demo/scenes" DESTINATION demo COMPONENT Runtime) +endif() + +if(BUILD_TESTING) + # Исправление бага: раньше CTest регистрировал ноль тестов. Regression- + # проверки остаются в обычных сборках, чтобы CI/local presets ловили + # возврат уже исправленных багов. + add_subdirectory(Tests) endif() if(BUILD_BENCHMARKS) diff --git a/CMakePresets.json b/CMakePresets.json index 336f8ca6..7a354ec2 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -58,8 +58,10 @@ { "name": "bench", "configurePreset": "bench", + "description": "Исправление бага: preset bench собирает benchmarks и latticelab_tests, чтобы smoke-проверка бенчмарков не проходила при отсутствующих regression-тестах.", "targets": [ - "benchmarks" + "benchmarks", + "latticelab_tests" ], "jobs": 8 } diff --git a/Engine/Consts.h b/Engine/Consts.h index 61a09ded..4f840f55 100644 --- a/Engine/Consts.h +++ b/Engine/Consts.h @@ -2,6 +2,9 @@ namespace Consts { inline constexpr float Epsilon = 1e-6f; + // Исправление бага: LJ/Coulomb kernels clamp tiny pair distances до этого + // floor, чтобы near-overlap saves не создавали non-finite force spikes. + inline constexpr float MinPairDistanceSqr = 0.25f; } namespace Units { diff --git a/Engine/NeighborSearch/NeighborList.cpp b/Engine/NeighborSearch/NeighborList.cpp index cbfb1a3e..5ff1e0ff 100644 --- a/Engine/NeighborSearch/NeighborList.cpp +++ b/Engine/NeighborSearch/NeighborList.cpp @@ -13,6 +13,9 @@ void NeighborList::setCutoff(float cutoff) { cutoff_ = cutoff; + // Исправление бага: ForceField нужен реальный physical cutoff отдельно от + // cutoff + skin, чтобы skin shell влиял на performance, а не на physics. + cutoffSqr_ = cutoff_ * cutoff_; listRadius_ = cutoff_ + skin_; listRadiusSqr_ = listRadius_ * listRadius_; valid_ = false; @@ -27,6 +30,7 @@ void NeighborList::setSkin(float skin) { void NeighborList::setParams(float cutoff, float skin) { cutoff_ = cutoff; + cutoffSqr_ = cutoff_ * cutoff_; skin_ = skin; listRadius_ = cutoff_ + skin_; listRadiusSqr_ = listRadius_ * listRadius_; @@ -63,6 +67,8 @@ void NeighborList::build(const AtomStorage& atoms, World& box) { const SpatialGrid& grid = box.getGrid(); const uint32_t atomCount = static_cast(atoms.size()); + const uint32_t mobileCount = static_cast(atoms.mobileCount()); + const bool hasFixedAtoms = mobileCount < atomCount; const float* RESTRICT x = atoms.xData(); const float* RESTRICT y = atoms.yData(); const float* RESTRICT z = atoms.zData(); @@ -75,7 +81,14 @@ void NeighborList::build(const AtomStorage& atoms, World& box) { const float yi = y[i]; const float zi = z[i]; // запись всех соседей атома в массив - writeAtomNeighbors(grid, x, y, z, i, xi, yi, zi, neighbors_); + // Исправление бага: fixed atoms находятся после mobile atoms, но ForceField + // обходит только mobile rows. Mixed mobile-fixed pairs хранятся здесь. + if (!hasFixedAtoms) { + writeAtomNeighbors(grid, x, y, z, i, xi, yi, zi, neighbors_); + } + else if (i < mobileCount) { + writeMixedMobileAtomNeighbors(grid, x, y, z, i, mobileCount, xi, yi, zi, neighbors_); + } offsets_[i + 1] = neighbors_.size(); } @@ -89,7 +102,9 @@ void NeighborList::build(const AtomStorage& atoms, World& box) { bool NeighborList::needsRebuild(const AtomStorage& atoms) const { const size_t n = atoms.mobileCount(); - if (!valid_ || n != refPosX_.size()) { + // Исправление бага: atom add/remove меняет row offsets, даже если mobile ref + // arrays выглядят valid, поэтому total atom count входит в rebuild gate. + if (!valid_ || n != refPosX_.size() || atoms.size() != atomCount()) { return true; } @@ -105,7 +120,7 @@ bool NeighborList::needsRebuild(const AtomStorage& atoms) const { const float* RESTRICT refZ = refPosZ_.data(); int rebuild = false; -#pragma GCC ivdep + LATTICELAB_IVDEP for (uint32_t i = 0; i < n; ++i) { const float dx = x[i] - refX[i]; const float dy = y[i] - refY[i]; @@ -134,6 +149,36 @@ uint32_t NeighborList::memoryBytes() const { void NeighborList::resetStats() { stats_.reset(); } +void NeighborList::writeMixedMobileAtomNeighbors(const SpatialGrid& grid, const float* x, const float* y, const float* z, + const uint32_t atomIndex, const uint32_t mobileCount, const float xi, const float yi, + const float zi, std::vector& outNeighbors) const { + const auto& offsets27 = grid.neighborOffsets27(); + const int center = grid.linearCellOfAtom(atomIndex); + + for (int k = 0; k < 27; ++k) { + for (uint32_t neighborIndex : grid.atomsInCell(center + offsets27[k])) { + if (neighborIndex == atomIndex) { + continue; + } + + // Исправление бага: mobile-mobile pairs сохраняют half-list ownership; + // каждая mobile-fixed pair принадлежит row мобильного атома. + const bool ownsMobilePair = neighborIndex < atomIndex; + const bool ownsFixedPair = neighborIndex >= mobileCount; + if (!ownsMobilePair && !ownsFixedPair) { + continue; + } + + const float dx = x[neighborIndex] - xi; + const float dy = y[neighborIndex] - yi; + const float dz = z[neighborIndex] - zi; + if (dx * dx + dy * dy + dz * dz <= listRadiusSqr_) { + outNeighbors.emplace_back(neighborIndex); + } + } + } +} + void NeighborList::reserveListBuffers(const AtomStorage& atoms) { const size_t prevCapacity = neighbors_.capacity(); neighbors_.clear(); diff --git a/Engine/NeighborSearch/NeighborList.h b/Engine/NeighborSearch/NeighborList.h index b55504a2..3321a952 100644 --- a/Engine/NeighborSearch/NeighborList.h +++ b/Engine/NeighborSearch/NeighborList.h @@ -24,6 +24,9 @@ class NeighborList { [[nodiscard]] uint32_t pairStorageSize() const; [[nodiscard]] uint32_t memoryBytes() const; [[nodiscard]] float cutoff() const { return cutoff_; } + // Исправление бага: значение открыто, чтобы ForceField пропускал skin-only + // pairs и skin не менял LJ/Coulomb physics. + [[nodiscard]] float cutoffSqr() const { return cutoffSqr_; } [[nodiscard]] float skin() const { return skin_; } [[nodiscard]] float listRadius() const { return listRadius_; } [[nodiscard]] bool isValid() const { return valid_; } @@ -57,6 +60,10 @@ class NeighborList { private: void reserveListBuffers(const AtomStorage& atoms); + // Исправление бага: fixed atoms хранятся после mobile atoms, поэтому mixed + // pairs должны писаться в mobile rows, которые ForceField реально обходит. + void writeMixedMobileAtomNeighbors(const SpatialGrid& grid, const float* x, const float* y, const float* z, uint32_t atomIndex, + uint32_t mobileCount, float xi, float yi, float zi, std::vector& outNeighbors) const; // uint32_t - 4 байта, максимальное количество пар в NL ~ 4 млрд std::vector neighbors_; @@ -67,6 +74,7 @@ class NeighborList { std::vector refPosZ_; float cutoff_ = 0.0f; + float cutoffSqr_ = 0.0f; float skin_ = 0.0f; float listRadius_ = 0.0f; float listRadiusSqr_ = 0.0f; diff --git a/Engine/Simulation.cpp b/Engine/Simulation.cpp index f36417a9..e458363b 100644 --- a/Engine/Simulation.cpp +++ b/Engine/Simulation.cpp @@ -8,6 +8,33 @@ #include "Engine/metrics/Profiler.h" #include "Engine/physics/Bond.h" +namespace { +constexpr float kDefaultDt = 0.01f; +constexpr float kMinDt = 0.0001f; +constexpr float kMaxDt = 0.05f; + +// Исправление бага: сохранённые файлы и UI-пути могут передать некорректный dt. +// Централизованный clamp здесь не пропускает invalid dt в integrator. +float sanitizeDt(float dt) { + if (!std::isfinite(dt) || dt <= 0.0f) { + return kDefaultDt; + } + return std::clamp(dt, kMinDt, kMaxDt); +} + +// Исправление бага: grid neighbor search сканирует только текущую cell и 26 +// соседних cells. Cell size должен покрывать cutoff + skin, иначе пары теряются. +void ensureGridCoversNeighborList(World& world) { + const float requiredCellSize = world.getNeighborList().listRadius(); + if (requiredCellSize <= 0.0f || world.getGridCellSize() >= requiredCellSize) { + return; + } + + world.setGridCellSize(requiredCellSize); + world.getGrid().rebuild(world.getAtomStorage().xDataSpan(), world.getAtomStorage().yDataSpan(), world.getAtomStorage().zDataSpan()); +} +} // namespace + Simulation::Simulation() = default; Simulation::WorldState& Simulation::activeState() { @@ -89,6 +116,7 @@ StepData Simulation::makeStepData(WorldState& state) { .world = state.world, .forceField = state.forceField_, .neighborList = state.world.getNeighborList(), + .simStep = state.sim_step, .allowBondFormation = state.bondFormationEnabled_, .accelDamping = state.integrator.accelDamping(), .dt = state.Dt, @@ -116,10 +144,6 @@ void Simulation::updateAll() { } void Simulation::updateState(WorldState& state) { - if (state.world.getNeighborList().needsRebuild(state.world.getAtomStorage())) { - state.world.getNeighborList().rebuildPipeline(state.world.getAtomStorage(), state.world, state.sim_step); - } - StepData stepData = makeStepData(state); state.integrator.step(stepData); state.metricsCacheValid_ = false; @@ -127,10 +151,31 @@ void Simulation::updateState(WorldState& state) { state.sim_time_ns += state.Dt * Units::kTimeUnitToNs; } +void Simulation::setDt(float dt) { + activeState().Dt = sanitizeDt(dt); +} + +void Simulation::setNeighborListCutoff(float cutoff) { + World& activeWorld = world(); + activeWorld.getNeighborList().setCutoff(cutoff); + ensureGridCoversNeighborList(activeWorld); +} + +void Simulation::setNeighborListSkin(float skin) { + World& activeWorld = world(); + activeWorld.getNeighborList().setSkin(skin); + ensureGridCoversNeighborList(activeWorld); +} + void Simulation::setSizeBox(Vec3f newSize, int cellSize) { World& activeWorld = world(); activeWorld.setWorldSize(newSize); - activeWorld.setGridCellSize(cellSize); + // Исправление бага: сохраняем корректность обхода 27 cells, даже если + // callers запрашивают cell size меньше active NeighborList radius. + const float requestedCellSize = cellSize > 0 ? static_cast(cellSize) : activeWorld.getGridCellSize(); + const float effectiveCellSize = std::max(requestedCellSize, activeWorld.getNeighborList().listRadius()); + activeWorld.setGridCellSize(effectiveCellSize); + activeWorld.getNeighborList().clear(); activeWorld.getGrid().rebuild(activeWorld.getAtomStorage().xDataSpan(), activeWorld.getAtomStorage().yDataSpan(), activeWorld.getAtomStorage().zDataSpan()); } diff --git a/Engine/Simulation.h b/Engine/Simulation.h index 85b399ad..d3865320 100644 --- a/Engine/Simulation.h +++ b/Engine/Simulation.h @@ -37,7 +37,9 @@ class Simulation { void removeAtom(size_t atomIndex); void addBond(size_t aIndex, size_t bIndex); - void setDt(float dt) { activeState().Dt = dt; } + // Исправление бага: этот setter валидирует dt для UI и путей загрузки; нельзя + // назначать WorldState::Dt напрямую из сериализованных данных. + void setDt(float dt); float getDt() const { return activeState().Dt; } void setIntegrator(Integrator::Scheme scheme) { activeState().integrator.setScheme(scheme); } Integrator::Scheme getIntegrator() const { return activeState().integrator.getScheme(); } @@ -97,9 +99,11 @@ class Simulation { bool isCoulombEnabled() const { return world().isCoulombEnabled(); } void setGravity(const Vec3f& gravity) { world().setGravity(gravity); } Vec3f getGravity() const { return world().getGravity(); } - void setNeighborListCutoff(float cutoff) { world().getNeighborList().setCutoff(cutoff); } + // Исправление бага: изменения cutoff/skin также сохраняют invariant + // размера ячейки grid, который нужен обходу 27 ячеек в NeighborList. + void setNeighborListCutoff(float cutoff); float getNeighborListCutoff() const { return world().getNeighborList().cutoff(); } - void setNeighborListSkin(float skin) { world().getNeighborList().setSkin(skin); } + void setNeighborListSkin(float skin); float getNeighborListSkin() const { return world().getNeighborList().skin(); } float getNeighborListRadius() const { return world().getNeighborList().listRadius(); } @@ -134,7 +138,9 @@ class Simulation { friend class SimulationStateIO; struct WorldState { - explicit WorldState(Vec3f size, Vec3f renderOffset) : world(size, renderOffset) { world.getNeighborList().setParams(5.f, 1.f); } + // Исправление бага: более широкий default skin снижает частоту rebuild + // после переноса validation NeighborList на момент расчёта force. + explicit WorldState(Vec3f size, Vec3f renderOffset) : world(size, renderOffset) { world.getNeighborList().setParams(5.f, 2.f); } World world; Integrator integrator; diff --git a/Engine/World.cpp b/Engine/World.cpp index d0b63fb9..87b81b51 100644 --- a/Engine/World.cpp +++ b/Engine/World.cpp @@ -11,6 +11,9 @@ void World::clear() { void World::addAtom(const Vec3f& start_coords, const Vec3f& start_speed, AtomData::Type type, bool fixed) { atomStorage_.addAtom(start_coords, start_speed, type, fixed); + // Исправление бага: изменение числа/позиции атомов делает кэшированные + // rows NeighborList устаревшими, поэтому список очищается перед rebuild grid. + neighborList_.clear(); grid.rebuild(atomStorage_.xDataSpan(), atomStorage_.yDataSpan(), atomStorage_.zDataSpan()); } @@ -46,5 +49,8 @@ void World::removeAtom(size_t atomIndex) { } atomStorage_.removeAtom(atomIndex); + // Исправление бага: удаление меняет местами rows в storage и устаревает + // offsets NeighborList; очищаем кэшированный список перед следующим force query. + neighborList_.clear(); grid.rebuild(atomStorage_.xDataSpan(), atomStorage_.yDataSpan(), atomStorage_.zDataSpan()); } diff --git a/Engine/io/SimulationStateIO.cpp b/Engine/io/SimulationStateIO.cpp index ff4ccc3d..723b9d8b 100644 --- a/Engine/io/SimulationStateIO.cpp +++ b/Engine/io/SimulationStateIO.cpp @@ -19,6 +19,9 @@ namespace { int type = 0; bool fixed = false; float charge = 0.0f; + // Исправление бага: отсутствующий text charge должен сохранять AtomData defaults; + // explicit zero charge представлен как hasCharge=true и charge=0. + bool hasCharge = false; }; std::string trim(std::string_view value) { @@ -186,6 +189,7 @@ namespace { } atom.fixed = (fixed != 0); atom.charge = 0.0f; + atom.hasCharge = false; atoms.emplace_back(atom); } else { @@ -325,8 +329,12 @@ namespace { continue; } atom.fixed = (fixed != 0); - if (!(stream >> atom.charge)) { + if (stream >> atom.charge) { + atom.hasCharge = true; + } + else { atom.charge = 0.0f; + atom.hasCharge = false; } atoms.emplace_back(atom); } @@ -357,7 +365,11 @@ namespace { } simulation.finalizeAtomBatch(); for (size_t i = 0; i < atoms.size(); ++i) { - simulation.atoms().charge(i) = atoms[i].charge; + // Исправление бага: только explicit saved charge переопределяет + // default, назначенный appendAtomFast. + if (atoms[i].hasCharge) { + simulation.atoms().charge(i) = atoms[i].charge; + } } for (const auto& [aIndex, bIndex] : bonds) { simulation.addBond(aIndex, bIndex); diff --git a/Engine/physics/AtomData.cpp b/Engine/physics/AtomData.cpp index 2b6b822c..026a0435 100644 --- a/Engine/physics/AtomData.cpp +++ b/Engine/physics/AtomData.cpp @@ -17,13 +17,15 @@ const std::array(AtomData::Type::COUNT)> A {18.998f, 0.5f, 1, 0.0f, IM_COL32(144, 224, 80, 255), 3.00f, 0.08f}, // F - Fluorine {20.180f, 0.5f, 0, 0.0f, IM_COL32(179, 227, 245, 255), 2.80f, 0.03f}, // Ne - Neon - {22.990f, 0.5f, 1, 0.0f, IM_COL32(171, 92, 242, 255), 4.00f, 0.12f}, // Na - Sodium + // Исправление бага: Na/Cl ionic charge defaults должны быть здесь, чтобы + // создание и text-load fallback использовали один source of truth. + {22.990f, 0.5f, 1, 1.0f, IM_COL32(171, 92, 242, 255), 4.00f, 0.12f}, // Na - Sodium {24.305f, 0.5f, 2, 0.0f, IM_COL32(138, 255, 0, 255), 3.60f, 0.13f}, // Mg - Magnesium {26.982f, 0.5f, 3, 0.0f, IM_COL32(191, 166, 166, 255), 3.40f, 0.14f}, // Al - Aluminum {28.085f, 0.5f, 4, 0.0f, IM_COL32(240, 200, 160, 255), 3.30f, 0.15f}, // Si - Silicon {30.974f, 0.5f, 5, 0.0f, IM_COL32(255, 128, 0, 255), 3.20f, 0.16f}, // P - Phosphorus {32.060f, 0.5f, 6, 0.0f, IM_COL32(255, 255, 48, 255), 3.20f, 0.18f}, // S - Sulfur - {35.450f, 0.5f, 7, 0.0f, IM_COL32(31, 240, 31, 255), 3.10f, 0.15f}, // Cl - Chlorine + {35.450f, 0.5f, 7, -1.0f, IM_COL32(31, 240, 31, 255), 3.10f, 0.15f}, // Cl - Chlorine {39.948f, 0.5f, 0, 0.0f, IM_COL32(128, 209, 227, 255), 3.00f, 0.07f}, // Ar - Argon {39.098f, 0.5f, 1, 0.0f, IM_COL32(143, 64, 212, 255), 4.80f, 0.18f}, // K - Potassium diff --git a/Engine/physics/AtomStorage.h b/Engine/physics/AtomStorage.h index 6b41ca83..fcf78725 100644 --- a/Engine/physics/AtomStorage.h +++ b/Engine/physics/AtomStorage.h @@ -186,13 +186,9 @@ class AtomStorage { const auto& props = AtomData::getProps(type); invMass_[count_] = 1.f / props.mass; - charge_[count_] = 0.f; - if (type == AtomData::Type::Cl) { - charge_[count_] = -1.f; - } - else if (type == AtomData::Type::Na) { - charge_[count_] = 1.f; - } + // Исправление бага: charge defaults раньше были hardcoded по type здесь. + // Теперь storage доверяет AtomData, чтобы все пути load/create совпадали. + charge_[count_] = props.defaultCharge; atomType_.emplace_back(type); valence_.emplace_back(props.maxValence); diff --git a/Engine/physics/ForceField.cpp b/Engine/physics/ForceField.cpp index 564d85ba..7f8b2599 100644 --- a/Engine/physics/ForceField.cpp +++ b/Engine/physics/ForceField.cpp @@ -5,6 +5,18 @@ #include "Engine/physics/AtomStorage.h" namespace { + // Исправление бага: rebuild до integration пропускал быстрые атомы после + // движения. Force-entry refresh ловит predicted positions и external + // AtomStorage mutations до использования neighbors в pair/bond force. + void refreshNeighborListIfNeeded(World& world, int simStep) { + AtomStorage& atoms = world.getAtomStorage(); + NeighborList& neighborList = world.getNeighborList(); + + if (neighborList.needsRebuild(atoms)) { + neighborList.rebuildPipeline(atoms, world, simStep); + } + } + template void computePairInteractionsImpl(AtomStorage& atoms, const NeighborList& neighborList, const LJForceField& ljForceField, const CoulombForceField& coulombForceField) { @@ -47,6 +59,11 @@ namespace { const float dy = atoms.posY(bIndex) - posY; const float dz = atoms.posZ(bIndex) - posZ; const float d2 = dx * dx + dy * dy + dz * dz; + // Исправление бага: NeighborList включает cutoff + skin для + // rebuild amortization, но LJ/Coulomb physics использует real cutoff. + if (d2 > neighborList.cutoffSqr()) { + continue; + } if constexpr (UseLJ) { ljForceField.pairInteraction(atoms, bIndex, dx, dy, dz, d2, *ljPairRow, forceX, forceY, forceZ, potentialEnergy); @@ -68,21 +85,32 @@ namespace { ForceField::ForceField() = default; -void ForceField::compute(World& world, bool allowBondFormation, float dt) const { +void ForceField::compute(World& world, bool allowBondFormation, float dt, int simStep) const { PROFILE_SCOPE("ForceField::compute"); AtomStorage& atoms = world.getAtomStorage(); Bond::List& bonds = world.getBonds(); NeighborList& neighborList = world.getNeighborList(); + refreshNeighborListIfNeeded(world, simStep); wallForceField_.compute(world); - computePairInteractions(world); + if (world.isLJEnabled() && world.isCoulombEnabled()) { + computePairInteractionsImpl(atoms, neighborList, ljForceField_, coulombForceField_); + } + else if (world.isLJEnabled()) { + computePairInteractionsImpl(atoms, neighborList, ljForceField_, coulombForceField_); + } + else if (world.isCoulombEnabled()) { + computePairInteractionsImpl(atoms, neighborList, ljForceField_, coulombForceField_); + } bondForceField_.compute(atoms, bonds, neighborList, allowBondFormation, dt); } void ForceField::computePairInteractions(World& world) const { PROFILE_SCOPE("ForceField::pairInteractions"); + refreshNeighborListIfNeeded(world, 0); + AtomStorage& atoms = world.getAtomStorage(); const NeighborList& neighborList = world.getNeighborList(); diff --git a/Engine/physics/ForceField.h b/Engine/physics/ForceField.h index 7a4e6c16..75b5cfa8 100644 --- a/Engine/physics/ForceField.h +++ b/Engine/physics/ForceField.h @@ -12,7 +12,11 @@ class ForceField { public: ForceField(); - void compute(World& world, bool allowBondFormation, float dt) const; + // Исправление бага: simStep позволяет force-entry NeighborList rebuilds + // сохранять accurate rebuild stats после движения атомов в этом step. + void compute(World& world, bool allowBondFormation, float dt, int simStep = 0) const; + // Исправление бага: direct pair-interaction calls тоже обновляют NeighborList, + // чтобы external position mutations не читали stale pair rows. void computePairInteractions(World& world) const; private: diff --git a/Engine/physics/ForceFields/CoulombForceField.cpp b/Engine/physics/ForceFields/CoulombForceField.cpp index 4d93c34f..3493632d 100644 --- a/Engine/physics/ForceFields/CoulombForceField.cpp +++ b/Engine/physics/ForceFields/CoulombForceField.cpp @@ -3,8 +3,53 @@ #include #include "Engine/Consts.h" +#include "Engine/NeighborSearch/NeighborList.h" #include "Engine/metrics/Profiler.h" void CoulombForceField::compute(AtomStorage& atoms, NeighborList& neighborList) const { - // TODO реализовать + PROFILE_SCOPE("CoulombForceField::compute"); + + // Исправление бага: этот public Coulomb-only path раньше был no-op. Теперь он + // обходит NeighborList rows и применяет тот же cutoff, что и fused path. + const auto& offsets = neighborList.offsets(); + const auto& neighbors = neighborList.neighbors(); + + for (size_t atomIndex = 0; atomIndex < atoms.mobileCount(); ++atomIndex) { + const uint32_t begin = offsets[atomIndex]; + const uint32_t end = offsets[atomIndex + 1]; + if (begin > end || static_cast(end) > neighbors.size()) { + continue; + } + + const float charge = atoms.charge(atomIndex); + if (charge == 0.0f) { + continue; + } + + const float posX = atoms.posX(atomIndex); + const float posY = atoms.posY(atomIndex); + const float posZ = atoms.posZ(atomIndex); + float forceX = atoms.forceX(atomIndex); + float forceY = atoms.forceY(atomIndex); + float forceZ = atoms.forceZ(atomIndex); + float potentialEnergy = atoms.energy(atomIndex); + + for (uint32_t p = begin; p < end; ++p) { + const uint32_t bIndex = neighbors[p]; + const float dx = atoms.posX(bIndex) - posX; + const float dy = atoms.posY(bIndex) - posY; + const float dz = atoms.posZ(bIndex) - posZ; + const float d2 = dx * dx + dy * dy + dz * dz; + if (d2 > neighborList.cutoffSqr()) { + continue; + } + + pairInteraction(atoms, bIndex, dx, dy, dz, d2, charge, forceX, forceY, forceZ, potentialEnergy); + } + + atoms.forceX(atomIndex) = forceX; + atoms.forceY(atomIndex) = forceY; + atoms.forceZ(atomIndex) = forceZ; + atoms.energy(atomIndex) = potentialEnergy; + } } diff --git a/Engine/physics/ForceFields/CoulombForceField.h b/Engine/physics/ForceFields/CoulombForceField.h index dcc194c8..518bab35 100644 --- a/Engine/physics/ForceFields/CoulombForceField.h +++ b/Engine/physics/ForceFields/CoulombForceField.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "Engine/Consts.h" @@ -24,9 +25,12 @@ class CoulombForceField { return; } + // Исправление бага: overlapping charged atoms раньше создавали force + // spikes из invR / d2; общий floor сохраняет finite math. + const float safeD2 = std::max(d2, Consts::MinPairDistanceSqr); const float qqScale = kCoulombEvAngstrom * chargeA * chargeB; - const float invR = 1.0f / std::sqrt(d2); - const float forceScale = qqScale * invR / d2; + const float invR = 1.0f / std::sqrt(safeD2); + const float forceScale = qqScale * invR / safeD2; const float potential = qqScale * invR; const float pairForceX = dx * forceScale; diff --git a/Engine/physics/ForceFields/LJForceField.h b/Engine/physics/ForceFields/LJForceField.h index 172e2a75..1507d43f 100644 --- a/Engine/physics/ForceFields/LJForceField.h +++ b/Engine/physics/ForceFields/LJForceField.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -33,7 +34,10 @@ class LJForceField { const LJParams& params = ljPairRow[static_cast(atoms.type(bIndex))]; - const float invD2 = 1.0f / d2; + // Исправление бага: near-overlap LJ pairs могли численно взрываться. + // Clamp применяется только к kernel distance, не меняя normal-distance interactions. + const float safeD2 = std::max(d2, Consts::MinPairDistanceSqr); + const float invD2 = 1.0f / safeD2; const float invD6 = invD2 * invD2 * invD2; const float invD12 = invD6 * invD6; diff --git a/Engine/physics/Integrator.cpp b/Engine/physics/Integrator.cpp index 886db887..87028b39 100644 --- a/Engine/physics/Integrator.cpp +++ b/Engine/physics/Integrator.cpp @@ -7,8 +7,8 @@ Integrator::Integrator() : integrator_type(Scheme::Verlet), scheme_impl(makeSchemeImpl(Scheme::Verlet)) {} void Integrator::setScheme(Scheme scheme) { - integrator_type = scheme; - scheme_impl = makeSchemeImpl(scheme); + integrator_type = canonicalizeScheme(scheme); + scheme_impl = makeSchemeImpl(integrator_type); } void Integrator::setMaxParticleSpeed(float maxSpeed) { maxParticleSpeed_ = std::max(0.0f, maxSpeed); } @@ -31,10 +31,25 @@ Integrator::SchemeVariant Integrator::makeSchemeImpl(Scheme scheme) { case Scheme::KDK: return KDKScheme{}; case Scheme::RK4: - return RK4Scheme{}; case Scheme::Langevin: - return LangevinScheme{}; + // Исправление бага: эти schemes ещё не implemented и раньше выглядели + // selectable, хотя внутри выполнялся Verlet. + return VerletScheme{}; default: return VerletScheme{}; } } + +Integrator::Scheme Integrator::canonicalizeScheme(Scheme scheme) { + switch (scheme) { + case Scheme::Verlet: + case Scheme::KDK: + return scheme; + case Scheme::RK4: + case Scheme::Langevin: + default: + // Исправление бага: сохраняем actual runtime scheme вместо misleading + // unsupported enum value. + return Scheme::Verlet; + } +} diff --git a/Engine/physics/Integrator.h b/Engine/physics/Integrator.h index 75106b5e..c3b3b41f 100644 --- a/Engine/physics/Integrator.h +++ b/Engine/physics/Integrator.h @@ -17,6 +17,7 @@ struct StepData { World& world; ForceField& forceField; NeighborList& neighborList; + size_t simStep; bool allowBondFormation; float accelDamping; float dt; @@ -45,6 +46,9 @@ class Integrator { private: using SchemeVariant = std::variant; + // Исправление бага: RK4/Langevin сейчас fall back to Verlet, поэтому public + // scheme state canonicalized, пока нет настоящих implementations. + static Scheme canonicalizeScheme(Scheme scheme); static SchemeVariant makeSchemeImpl(Scheme scheme); Scheme integrator_type = Scheme::Verlet; diff --git a/Engine/physics/integrators/KDKScheme.cpp b/Engine/physics/integrators/KDKScheme.cpp index ab459c01..9176cd82 100644 --- a/Engine/physics/integrators/KDKScheme.cpp +++ b/Engine/physics/integrators/KDKScheme.cpp @@ -9,6 +9,8 @@ void KDKScheme::pipeline(StepData& stepData) const { halfKick(stepData.world.getAtomStorage(), stepData.accelDamping, stepData.dt); // Расчет новых позиций StepOps::predictAndSync(stepData, &drift); + // Исправление бага: computeForces обновляет NeighborList после drift, поэтому + // KDK получает то же исправление fast-atom stale-list, что и Verlet. // Расчет сил StepOps::computeForces(stepData); // Kick: вторая половина шага diff --git a/Engine/physics/integrators/StepOps.h b/Engine/physics/integrators/StepOps.h index 868c69a0..3f4c5f63 100644 --- a/Engine/physics/integrators/StepOps.h +++ b/Engine/physics/integrators/StepOps.h @@ -52,7 +52,7 @@ namespace StepOps { float* RESTRICT vz = atomStorage.vzData(); const size_t mobileCount = atomStorage.mobileCount(); -#pragma GCC ivdep + LATTICELAB_IVDEP for (size_t atomIndex = 0; atomIndex < mobileCount; ++atomIndex) { float vxValue = vx[atomIndex]; float vyValue = vy[atomIndex]; @@ -74,7 +74,20 @@ namespace StepOps { inline void computeForces(StepData& stepData) { PROFILE_SCOPE("StepOps::computeForces"); - stepData.forceField.compute(stepData.world, stepData.allowBondFormation, stepData.dt); + // Исправление бага: ForceField обновляет NeighborList здесь после predict, + // исправляя быстрые атомы, которые проходили old skin gate внутри step. + stepData.forceField.compute(stepData.world, stepData.allowBondFormation, stepData.dt, static_cast(stepData.simStep)); + } + + inline void refreshNeighborListIfNeeded(StepData& stepData) { + PROFILE_SCOPE("StepOps::refreshNeighborListIfNeeded"); + // Исправление бага: оставлено для benchmark/test setup; обычные force + // paths избегают duplicate scans через refresh внутри ForceField. + AtomStorage& atomStorage = stepData.world.getAtomStorage(); + + if (stepData.neighborList.needsRebuild(atomStorage)) { + stepData.neighborList.rebuildPipeline(atomStorage, stepData.world, static_cast(stepData.simStep)); + } } template diff --git a/Engine/physics/integrators/VerletScheme.cpp b/Engine/physics/integrators/VerletScheme.cpp index 2e35cf7c..482cdefc 100644 --- a/Engine/physics/integrators/VerletScheme.cpp +++ b/Engine/physics/integrators/VerletScheme.cpp @@ -7,6 +7,8 @@ void VerletScheme::pipeline(StepData& stepData) const { PROFILE_SCOPE("VerletScheme::pipeline"); // Расчет новых позиций StepOps::predictAndSync(stepData, &predict); + // Исправление бага: computeForces обновляет NeighborList после движения, + // поэтому быстрые атомы не используют устаревшие pairs из состояния до predict. // Расчет сил StepOps::computeForces(stepData); // Корректировка скоростей @@ -30,7 +32,7 @@ void VerletScheme::predict(AtomStorage& atomStorage, float dt) { const float* RESTRICT invMass = atomStorage.invMassData(); -#pragma GCC ivdep + LATTICELAB_IVDEP for (size_t i = 0; i < n; ++i) { x[i] += (vx[i] + fx[i] * invMass[i] * 0.5f * dt) * dt; y[i] += (vy[i] + fy[i] * invMass[i] * 0.5f * dt) * dt; @@ -56,7 +58,7 @@ void VerletScheme::correct(AtomStorage& atomStorage, float accelDamping, float d const float* RESTRICT invMass = atomStorage.invMassData(); -#pragma GCC ivdep + LATTICELAB_IVDEP for (size_t i = 0; i < n; ++i) { const float halfDtInvMass = 0.5f * accelDamping * dt * invMass[i]; diff --git a/Engine/restrict.h b/Engine/restrict.h index 956d9bc2..372de6f1 100644 --- a/Engine/restrict.h +++ b/Engine/restrict.h @@ -1,8 +1,13 @@ #pragma once +// Исправление бага: прямой `#pragma GCC ivdep` давал MSVC warning C4068. +// Вместо него call sites используют один macro, зависящий от compiler. #if defined(_MSC_VER) #define RESTRICT __restrict +#define LATTICELAB_IVDEP __pragma(loop(ivdep)) #elif defined(__GNUC__) || defined(__clang__) #define RESTRICT __restrict__ +#define LATTICELAB_IVDEP _Pragma("GCC ivdep") #else #define RESTRICT +#define LATTICELAB_IVDEP #endif diff --git a/GUI/interface/panels/io/ioPanel.cpp b/GUI/interface/panels/io/ioPanel.cpp index c6e79fbb..cf1ae9f8 100644 --- a/GUI/interface/panels/io/ioPanel.cpp +++ b/GUI/interface/panels/io/ioPanel.cpp @@ -104,7 +104,10 @@ void IOPanel::draw(float scale, Vec2i windowSize, Simulation& simulation, FileDi if (uiState.captureAvailable) { const char* captureLabel = uiState.captureRecording ? "Стоп" : "Запись"; - if (ImGui::Button(captureLabel, ImVec2(saveButtonWidth * scale, 0.f))) { + // Исправление бага: этот visible text меняется во время recording; + // stable hidden ID не даёт ImGui считать его другим control. + const std::string captureButtonLabel = std::string(captureLabel) + "##capture_toggle"; + if (ImGui::Button(captureButtonLabel.c_str(), ImVec2(saveButtonWidth * scale, 0.f))) { AppSignals::Capture::ToggleRecording.emit(); } drawIOPanelCaptureStatus(uiState); diff --git a/GUI/interface/panels/sim_control/SimControlPanel.cpp b/GUI/interface/panels/sim_control/SimControlPanel.cpp index e742ffab..776405f3 100644 --- a/GUI/interface/panels/sim_control/SimControlPanel.cpp +++ b/GUI/interface/panels/sim_control/SimControlPanel.cpp @@ -5,15 +5,17 @@ #include "App/AppSignals.h" -#define ICON_FA_PAUSE "\uf04c" -#define ICON_FA_PLAY "\uf04b" -#define ICON_FA_STEP_FORWARD "\uf051" +#define ICON_FA_PAUSE "\xEF\x81\x8C" +#define ICON_FA_PLAY "\xEF\x81\x8B" +#define ICON_FA_STEP_FORWARD "\xEF\x81\x91" static const ImVec4 ACTIVE_COLOR = ImVec4(0.06f, 0.53f, 0.98f, 1.00f); static const ImVec4 DISABLED_BUTTON_COLOR = ImVec4(0.26f, 0.28f, 0.31f, 1.00f); static const ImVec4 DISABLED_BUTTON_HOVERED_COLOR = ImVec4(0.26f, 0.28f, 0.31f, 1.00f); static const ImVec4 DISABLED_BUTTON_ACTIVE_COLOR = ImVec4(0.26f, 0.28f, 0.31f, 1.00f); +// Исправление бага: play/pause/step являются icon-only controls, поэтому их labels +// содержат ##hidden IDs для стабильного ImGui state без видимых изменений. static void pushActiveColor() { ImGui::PushStyleColor(ImGuiCol_Button, ACTIVE_COLOR); ImGui::PushStyleColor(ImGuiCol_ButtonActive, ACTIVE_COLOR); @@ -57,7 +59,7 @@ void SimControlPanel::draw(float scale, Vec2i windowSize, bool& pause, float& si ImGui::PushStyleColor(ImGuiCol_ButtonActive, DISABLED_BUTTON_ACTIVE_COLOR); } ImGui::BeginDisabled(!pause); - if (ImGui::Button(ICON_FA_STEP_FORWARD, ImVec2(50 * scale, 50 * scale))) { + if (ImGui::Button(ICON_FA_STEP_FORWARD "##step_physics", ImVec2(50 * scale, 50 * scale))) { AppSignals::UI::StepPhysics.emit(); } ImGui::EndDisabled(); @@ -71,7 +73,7 @@ void SimControlPanel::draw(float scale, Vec2i windowSize, bool& pause, float& si if (playButtonHighlighted) { pushActiveColor(); } - if (ImGui::Button(pause ? ICON_FA_PLAY : ICON_FA_PAUSE, ImVec2(50 * scale, 50 * scale))) { + if (ImGui::Button(pause ? ICON_FA_PLAY "##toggle_pause" : ICON_FA_PAUSE "##toggle_pause", ImVec2(50 * scale, 50 * scale))) { pause = !pause; } if (playButtonHighlighted) { diff --git a/GUI/interface/panels/tools/SideToolsPanel.cpp b/GUI/interface/panels/tools/SideToolsPanel.cpp index fb374754..8928e71a 100644 --- a/GUI/interface/panels/tools/SideToolsPanel.cpp +++ b/GUI/interface/panels/tools/SideToolsPanel.cpp @@ -3,39 +3,41 @@ #include #include -#define ICON_FA_MOUSE_POINTER "\uf245" -#define ICON_FA_VECTOR_SQUARE "\uf5cb" -#define ICON_FA_DRAW_POLYGON "\uf5ee" -#define ICON_FA_RULER "\uf545" -#define ICON_FA_PLUS "\uf067" -#define ICON_FA_MINUS "\uf068" +#define ICON_FA_MOUSE_POINTER "\xEF\x89\x85" +#define ICON_FA_VECTOR_SQUARE "\xEF\x97\x8B" +#define ICON_FA_DRAW_POLYGON "\xEF\x97\xAE" +#define ICON_FA_RULER "\xEF\x95\x85" +#define ICON_FA_PLUS "\xEF\x81\xA7" +#define ICON_FA_MINUS "\xEF\x81\xA8" namespace { constexpr ImVec4 ACTIVE_COLOR = ImVec4(0.06f, 0.53f, 0.98f, 1.00f); struct ToolItem { SideToolsPanel::Tool tool; - const char* icon; + const char* label; const char* tooltip; }; + // Исправление бага: каждый icon-only tool получает stable ##hidden ID, чтобы + // ImGui не объединял startup widgets с одинаково выглядящими labels. constexpr std::array TOOL_ITEMS{{ - {SideToolsPanel::Tool::Cursor, ICON_FA_MOUSE_POINTER, "Cursor"}, - {SideToolsPanel::Tool::Frame, ICON_FA_VECTOR_SQUARE, "Frame select"}, - {SideToolsPanel::Tool::Lasso, ICON_FA_DRAW_POLYGON, "Lasso select"}, - {SideToolsPanel::Tool::Ruler, ICON_FA_RULER, "Ruler"}, - {SideToolsPanel::Tool::AddAtom, ICON_FA_PLUS, "Add atom"}, - {SideToolsPanel::Tool::RemoveAtom, ICON_FA_MINUS, "Remove atom"}, + {SideToolsPanel::Tool::Cursor, ICON_FA_MOUSE_POINTER "##cursor_tool", "Cursor"}, + {SideToolsPanel::Tool::Frame, ICON_FA_VECTOR_SQUARE "##frame_tool", "Frame select"}, + {SideToolsPanel::Tool::Lasso, ICON_FA_DRAW_POLYGON "##lasso_tool", "Lasso select"}, + {SideToolsPanel::Tool::Ruler, ICON_FA_RULER "##ruler_tool", "Ruler"}, + {SideToolsPanel::Tool::AddAtom, ICON_FA_PLUS "##add_atom_tool", "Add atom"}, + {SideToolsPanel::Tool::RemoveAtom, ICON_FA_MINUS "##remove_atom_tool", "Remove atom"}, }}; - bool drawToolButton(const char* icon, const char* tooltip, bool selected, float buttonSize, ImFont* textFont) { + bool drawToolButton(const char* label, const char* tooltip, bool selected, float buttonSize, ImFont* textFont) { if (selected) { ImGui::PushStyleColor(ImGuiCol_Button, ACTIVE_COLOR); ImGui::PushStyleColor(ImGuiCol_ButtonActive, ACTIVE_COLOR); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ACTIVE_COLOR); } - const bool pressed = ImGui::Button(icon, ImVec2(buttonSize, buttonSize)); + const bool pressed = ImGui::Button(label, ImVec2(buttonSize, buttonSize)); if (selected) { ImGui::PopStyleColor(3); @@ -93,7 +95,7 @@ void SideToolsPanel::draw(float scale, Vec2i windowSize, ImFont* iconFont, ImFon } for (const ToolItem& item : TOOL_ITEMS) { - if (drawToolButton(item.icon, item.tooltip, selectedTool == item.tool, buttonSize, textFont)) { + if (drawToolButton(item.label, item.tooltip, selectedTool == item.tool, buttonSize, textFont)) { selectedTool = item.tool; } } diff --git a/GUI/interface/panels/tools/ToolsPanel.cpp b/GUI/interface/panels/tools/ToolsPanel.cpp index 732f9d2f..86f8c24b 100644 --- a/GUI/interface/panels/tools/ToolsPanel.cpp +++ b/GUI/interface/panels/tools/ToolsPanel.cpp @@ -8,12 +8,14 @@ #include "GUI/interface/panels/settings/SettingsPanel.h" #include "Rendering/camera/Camera.h" -#define ICON_FA_FLASK "\uf0c3" -#define ICON_FA_COG "\uf013" -#define ICON_FA_BUG "\uf188" -#define ICON_FA_SYNC_ALT "\uf2f1" -#define ICON_FA_STREET_VIEW "\uf21d" +#define ICON_FA_FLASK "\xEF\x83\x83" +#define ICON_FA_COG "\xEF\x80\x93" +#define ICON_FA_BUG "\xEF\x86\x88" +#define ICON_FA_SYNC_ALT "\xEF\x8B\xB1" +#define ICON_FA_STREET_VIEW "\xEF\x88\x9D" +// Исправление бага: toolbar controls являются icon-only или dynamic-label ImGui widgets. +// Каждый visible label теперь содержит ##hidden ID, чтобы UI state не конфликтовал. void ToolsPanel::setRendererType(RendererType type) { is3D = type == RendererType::Renderer3D; if (!is3D) { @@ -40,13 +42,13 @@ void ToolsPanel::draw(float scale, DebugPanel& debug, SettingsPanel& settings, I const float x = std::round(baseLeftOffset * scale); const float y = std::round(baseTopOffset * scale); - auto drawActiveButton = [&](const char* icon, bool visible) { + auto drawActiveButton = [&](const char* label, bool visible) { if (visible) { ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.06f, 0.53f, 0.98f, 1.00f)); ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.06f, 0.53f, 0.98f, 1.00f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.06f, 0.53f, 0.98f, 1.00f)); } - const bool clicked = ImGui::Button(icon, ImVec2(buttonSize, buttonSize)); + const bool clicked = ImGui::Button(label, ImVec2(buttonSize, buttonSize)); if (visible) { ImGui::PopStyleColor(3); } @@ -60,7 +62,7 @@ void ToolsPanel::draw(float scale, DebugPanel& debug, SettingsPanel& settings, I ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(spacingX, 0.0f)); ImGui::Begin("Tools", nullptr, PANEL_FLAGS); - if (drawActiveButton(ICON_FA_COG, settings.isVisible())) { + if (drawActiveButton(ICON_FA_COG "##settings_panel", settings.isVisible())) { if (settings.isVisible()) { settings.close(); } @@ -72,7 +74,7 @@ void ToolsPanel::draw(float scale, DebugPanel& debug, SettingsPanel& settings, I } ImGui::SameLine(); - if (drawActiveButton(ICON_FA_FLASK, ioPanel.isVisible())) { + if (drawActiveButton(ICON_FA_FLASK "##io_panel", ioPanel.isVisible())) { if (ioPanel.isVisible()) { ioPanel.close(); } @@ -84,7 +86,7 @@ void ToolsPanel::draw(float scale, DebugPanel& debug, SettingsPanel& settings, I } ImGui::SameLine(); - if (drawActiveButton(ICON_FA_BUG, debug.isVisible())) { + if (drawActiveButton(ICON_FA_BUG "##debug_panel", debug.isVisible())) { if (debug.isVisible()) { debug.close(); } @@ -96,7 +98,7 @@ void ToolsPanel::draw(float scale, DebugPanel& debug, SettingsPanel& settings, I } ImGui::SameLine(); - if (ImGui::Button(is3D ? "3D" : "2D", ImVec2(buttonSize, buttonSize))) { + if (ImGui::Button(is3D ? "3D##renderer_mode" : "2D##renderer_mode", ImVec2(buttonSize, buttonSize))) { is3D = !is3D; if (!is3D) { isFree = false; @@ -105,7 +107,8 @@ void ToolsPanel::draw(float scale, DebugPanel& debug, SettingsPanel& settings, I } if (is3D) { ImGui::SameLine(); - if (ImGui::Button(isFree ? ICON_FA_STREET_VIEW : ICON_FA_SYNC_ALT, ImVec2(buttonSize, buttonSize))) { + if (ImGui::Button(isFree ? ICON_FA_STREET_VIEW "##camera_mode" : ICON_FA_SYNC_ALT "##camera_mode", + ImVec2(buttonSize, buttonSize))) { isFree = !isFree; AppSignals::UI::SetCameraMode.emit(isFree ? Camera::Mode::Free : Camera::Mode::Orbit); } diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt new file mode 100644 index 00000000..dd352980 --- /dev/null +++ b/Tests/CMakeLists.txt @@ -0,0 +1,15 @@ +# Исправление бага: раньше CTest имел ноль зарегистрированных тестов. Этот +# executable собирает точечные regression-проверки для багов physics/load/package. +add_executable(latticelab_tests + RegressionTests.cpp +) + +target_link_libraries(latticelab_tests PRIVATE latticelab_lib) + +if(COMMAND target_copy_webgpu_binaries) + # Исправление бага: тесты линкуют тот же backend-код, что и приложение, + # поэтому WebGPU runtime DLL тоже копируется рядом с executable тестов. + target_copy_webgpu_binaries(latticelab_tests) +endif() + +add_test(NAME latticelab.regression COMMAND latticelab_tests) diff --git a/Tests/RegressionTests.cpp b/Tests/RegressionTests.cpp new file mode 100644 index 00000000..a6c3c89d --- /dev/null +++ b/Tests/RegressionTests.cpp @@ -0,0 +1,270 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Engine/Simulation.h" +#include "Engine/io/SimulationStateIO.h" +#include "Engine/physics/ForceFields/CoulombForceField.h" + +// Исправление бага: этот набор тестов прямо называет исправленные баги, чтобы +// будущие правки сохраняли проверки dt, NeighborList, загрузки charge и устойчивости. + +namespace { +constexpr float kForceEpsilon = 1.0e-6f; + +[[noreturn]] void fail(std::string_view message) { + throw std::runtime_error(std::string(message)); +} + +void require(bool condition, std::string_view message) { + if (!condition) { + fail(message); + } +} + +float forceMagnitude(const AtomStorage& atoms, size_t atomIndex) { + const float fx = atoms.forceX(atomIndex); + const float fy = atoms.forceY(atomIndex); + const float fz = atoms.forceZ(atomIndex); + return std::sqrt(fx * fx + fy * fy + fz * fz); +} + +bool atomStateIsFinite(const AtomStorage& atoms, size_t atomIndex) { + return std::isfinite(atoms.forceX(atomIndex)) && std::isfinite(atoms.forceY(atomIndex)) && + std::isfinite(atoms.forceZ(atomIndex)) && std::isfinite(atoms.energy(atomIndex)); +} + +void rebuildNeighborList(Simulation& simulation) { + simulation.neighborList().rebuildPipeline(simulation.atoms(), simulation.world(), static_cast(simulation.getSimStep())); +} + +void testDtSanitizesInvalidValues() { + Simulation simulation; + simulation.createWorld({10.0f, 10.0f, 10.0f}); + + simulation.setDt(-1.0f); + require(std::isfinite(simulation.getDt()), "negative dt must sanitize to a finite value"); + require(simulation.getDt() > 0.0f, "negative dt must sanitize to a positive value"); + + simulation.setDt(std::numeric_limits::quiet_NaN()); + require(std::isfinite(simulation.getDt()), "NaN dt must sanitize to a finite value"); + require(simulation.getDt() > 0.0f, "NaN dt must sanitize to a positive value"); +} + +void testUnsupportedIntegratorsCanonicalizeToVerlet() { + Simulation simulation; + simulation.createWorld({10.0f, 10.0f, 10.0f}); + + simulation.setIntegrator(Integrator::Scheme::RK4); + require(simulation.getIntegrator() == Integrator::Scheme::Verlet, "RK4 must canonicalize to Verlet until implemented"); + + simulation.setIntegrator(Integrator::Scheme::Langevin); + require(simulation.getIntegrator() == Integrator::Scheme::Verlet, "Langevin must canonicalize to Verlet until implemented"); + + simulation.setIntegrator(Integrator::Scheme::KDK); + require(simulation.getIntegrator() == Integrator::Scheme::KDK, "implemented KDK scheme must remain selectable"); +} + +void testSkinDoesNotChangePhysicalCutoff() { + Simulation simulation; + simulation.createWorld({20.0f, 20.0f, 20.0f}); + simulation.setLJEnabled(true); + simulation.setCoulombEnabled(false); + simulation.setNeighborListCutoff(1.0f); + simulation.setNeighborListSkin(2.0f); + + simulation.createAtom({5.0f, 5.0f, 5.0f}, {0.0f, 0.0f, 0.0f}, AtomData::Type::H); + simulation.createAtom({6.5f, 5.0f, 5.0f}, {0.0f, 0.0f, 0.0f}, AtomData::Type::H); + rebuildNeighborList(simulation); + + require(simulation.neighborList().pairStorageSize() > 0, "pair inside skin must be present in NeighborList"); + simulation.forceField().computePairInteractions(simulation.world()); + + require(forceMagnitude(simulation.atoms(), 0) <= kForceEpsilon, "pair outside physical cutoff must not force atom 0"); + require(forceMagnitude(simulation.atoms(), 1) <= kForceEpsilon, "pair outside physical cutoff must not force atom 1"); +} + +void testMobileFixedPairContributesToNonBondedForces() { + Simulation simulation; + simulation.createWorld({20.0f, 20.0f, 20.0f}); + simulation.setLJEnabled(true); + simulation.setCoulombEnabled(false); + simulation.setNeighborListCutoff(3.0f); + simulation.setNeighborListSkin(0.1f); + + simulation.createAtom({5.0f, 5.0f, 5.0f}, {0.0f, 0.0f, 0.0f}, AtomData::Type::H, false); + simulation.createAtom({6.5f, 5.0f, 5.0f}, {0.0f, 0.0f, 0.0f}, AtomData::Type::H, true); + rebuildNeighborList(simulation); + + simulation.forceField().computePairInteractions(simulation.world()); + require(forceMagnitude(simulation.atoms(), 0) > kForceEpsilon, "mobile-fixed pair inside cutoff must force mobile atom"); +} + +void testCoulombOnlyComputeUsesNeighborList() { + Simulation simulation; + simulation.createWorld({20.0f, 20.0f, 20.0f}); + simulation.setNeighborListCutoff(3.0f); + simulation.setNeighborListSkin(0.1f); + simulation.createAtom({5.0f, 5.0f, 5.0f}, {0.0f, 0.0f, 0.0f}, AtomData::Type::H); + simulation.createAtom({6.5f, 5.0f, 5.0f}, {0.0f, 0.0f, 0.0f}, AtomData::Type::H); + simulation.atoms().charge(0) = 1.0f; + simulation.atoms().charge(1) = -1.0f; + rebuildNeighborList(simulation); + + CoulombForceField coulomb; + coulomb.compute(simulation.atoms(), simulation.neighborList()); + + require(forceMagnitude(simulation.atoms(), 0) > kForceEpsilon, "Coulomb-only compute must force charged atom 0"); + require(forceMagnitude(simulation.atoms(), 1) > kForceEpsilon, "Coulomb-only compute must force charged atom 1"); +} + +void testAtomDataCarriesNaClDefaultCharges() { + require(AtomData::getProps(AtomData::Type::Na).defaultCharge > 0.0f, "Na default charge must be positive in AtomData"); + require(AtomData::getProps(AtomData::Type::Cl).defaultCharge < 0.0f, "Cl default charge must be negative in AtomData"); +} + +void testCellSizeCoversNeighborListRadius() { + Simulation simulation; + simulation.createWorld({40.0f, 40.0f, 40.0f}); + simulation.setNeighborListCutoff(5.0f); + simulation.setNeighborListSkin(1.0f); + simulation.setSizeBox({40.0f, 40.0f, 40.0f}, 1); + + require(simulation.world().getGridCellSize() >= simulation.getNeighborListRadius(), + "grid cell size must cover cutoff + skin for 27-cell traversal"); +} + +void testFastAtomRefreshesNeighborListBeforeForces() { + Simulation simulation; + simulation.createWorld({20.0f, 20.0f, 20.0f}); + simulation.setLJEnabled(true); + simulation.setCoulombEnabled(false); + simulation.setNeighborListCutoff(1.0f); + simulation.setNeighborListSkin(0.1f); + simulation.setDt(0.01f); + + simulation.createAtom({4.0f, 5.0f, 5.0f}, {110.0f, 0.0f, 0.0f}, AtomData::Type::H); + simulation.createAtom({6.0f, 5.0f, 5.0f}, {0.0f, 0.0f, 0.0f}, AtomData::Type::H); + + simulation.update(); + + require(simulation.neighborList().pairStorageSize() > 0, + "fast atom crossing into cutoff must refresh NeighborList in the same step"); + require(forceMagnitude(simulation.atoms(), 0) > kForceEpsilon, + "fast atom crossing into cutoff must receive non-bonded force in the same step"); +} + +void testTextLoadMissingChargeUsesAtomDefault() { + const char* path = "lat_missing_charge_regression.lat"; + std::remove(path); + + { + std::ofstream file(path, std::ios::trunc); + file << "[meta]\n"; + file << " format lat\n"; + file << " version 1\n\n"; + file << "[scene]\n"; + file << " box 10 10 10\n"; + file << " dt 0.01\n\n"; + file << "[atoms]\n"; + file << " count 2\n"; + file << " atom 1 1 1 0 0 0 " << static_cast(AtomData::Type::Na) << " 0\n"; + file << " atom 2 1 1 0 0 0 " << static_cast(AtomData::Type::Cl) << " 0 0\n"; + } + + Simulation simulation; + simulation.createWorld({10.0f, 10.0f, 10.0f}); + SimulationStateIO::load(simulation, path); + std::remove(path); + + require(simulation.atoms().size() == 2, "text load must create both atoms"); + require(simulation.atoms().charge(0) == AtomData::getProps(AtomData::Type::Na).defaultCharge, + "missing text charge must preserve atom default charge"); + require(simulation.atoms().charge(1) == 0.0f, "explicit zero text charge must remain zero"); +} + +void testNearOverlapForcesRemainFinite() { + Simulation simulation; + simulation.createWorld({10.0f, 10.0f, 10.0f}); + simulation.setLJEnabled(true); + simulation.setCoulombEnabled(true); + simulation.setNeighborListCutoff(3.0f); + simulation.setNeighborListSkin(0.1f); + simulation.createAtom({5.0f, 5.0f, 5.0f}, {0.0f, 0.0f, 0.0f}, AtomData::Type::H); + simulation.createAtom({5.0011f, 5.0f, 5.0f}, {0.0f, 0.0f, 0.0f}, AtomData::Type::H); + simulation.atoms().charge(0) = 1.0f; + simulation.atoms().charge(1) = -1.0f; + rebuildNeighborList(simulation); + + simulation.forceField().computePairInteractions(simulation.world()); + + require(atomStateIsFinite(simulation.atoms(), 0), "near-overlap atom 0 force and energy must remain finite"); + require(atomStateIsFinite(simulation.atoms(), 1), "near-overlap atom 1 force and energy must remain finite"); +} + +void testPairForceRefreshesAfterExternalPositionMutation() { + Simulation simulation; + simulation.createWorld({20.0f, 20.0f, 20.0f}); + simulation.setLJEnabled(true); + simulation.setCoulombEnabled(false); + simulation.setNeighborListCutoff(2.0f); + simulation.setNeighborListSkin(0.1f); + simulation.createAtom({5.0f, 5.0f, 5.0f}, {0.0f, 0.0f, 0.0f}, AtomData::Type::H); + simulation.createAtom({10.0f, 5.0f, 5.0f}, {0.0f, 0.0f, 0.0f}, AtomData::Type::H); + rebuildNeighborList(simulation); + + simulation.atoms().setPos(1, {6.0f, 5.0f, 5.0f}); + simulation.forceField().computePairInteractions(simulation.world()); + + require(forceMagnitude(simulation.atoms(), 0) > kForceEpsilon, + "pair force path must refresh NeighborList after external position mutation"); +} + +using TestFn = void (*)(); + +struct TestCase { + std::string_view name; + TestFn fn; +}; + +constexpr TestCase kTests[] = { + {"DtSanitizesInvalidValues", testDtSanitizesInvalidValues}, + {"UnsupportedIntegratorsCanonicalizeToVerlet", testUnsupportedIntegratorsCanonicalizeToVerlet}, + {"SkinDoesNotChangePhysicalCutoff", testSkinDoesNotChangePhysicalCutoff}, + {"MobileFixedPairContributesToNonBondedForces", testMobileFixedPairContributesToNonBondedForces}, + {"CoulombOnlyComputeUsesNeighborList", testCoulombOnlyComputeUsesNeighborList}, + {"AtomDataCarriesNaClDefaultCharges", testAtomDataCarriesNaClDefaultCharges}, + {"CellSizeCoversNeighborListRadius", testCellSizeCoversNeighborListRadius}, + {"FastAtomRefreshesNeighborListBeforeForces", testFastAtomRefreshesNeighborListBeforeForces}, + {"TextLoadMissingChargeUsesAtomDefault", testTextLoadMissingChargeUsesAtomDefault}, + {"NearOverlapForcesRemainFinite", testNearOverlapForcesRemainFinite}, + {"PairForceRefreshesAfterExternalPositionMutation", testPairForceRefreshesAfterExternalPositionMutation}, +}; +} // namespace + +int main() { + int failures = 0; + for (const TestCase& test : kTests) { + try { + test.fn(); + std::cout << "[PASS] " << test.name << '\n'; + } + catch (const std::exception& ex) { + ++failures; + std::cerr << "[FAIL] " << test.name << ": " << ex.what() << '\n'; + } + } + + if (failures > 0) { + std::cerr << failures << " regression test(s) failed\n"; + return 1; + } + + std::cout << "All regression tests passed\n"; + return 0; +} diff --git a/main.cpp b/main.cpp index 05cc71c3..82d1b67c 100644 --- a/main.cpp +++ b/main.cpp @@ -1,6 +1,24 @@ -#include "App/Application.h" +#include "App/Application.h" -int main() { +namespace { + +int runApplication() { Application application; return application.run(); } + +} // namespace + +#ifdef _WIN32 +#include + +// Исправление бага: Windows-target имеет тип WIN32, поэтому линкер ожидает WinMain. +// Обе точки входа используют runApplication(), чтобы не дублировать логику запуска. +int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int) { + return runApplication(); +} +#endif + +int main() { + return runApplication(); +}