diff --git a/source/MRTest/MRTest.vcxproj b/source/MRTest/MRTest.vcxproj
index 9cd1df449347..5e70b47dd954 100644
--- a/source/MRTest/MRTest.vcxproj
+++ b/source/MRTest/MRTest.vcxproj
@@ -61,6 +61,7 @@
+
diff --git a/source/MRTest/MRTest.vcxproj.filters b/source/MRTest/MRTest.vcxproj.filters
index bc3ead9c660e..04f74f5f5671 100644
--- a/source/MRTest/MRTest.vcxproj.filters
+++ b/source/MRTest/MRTest.vcxproj.filters
@@ -184,6 +184,9 @@
Source Files
+
+ Source Files
+
diff --git a/source/MRTest/MRZipCompressTests.cpp b/source/MRTest/MRZipCompressTests.cpp
new file mode 100644
index 000000000000..ee1f34f6821c
--- /dev/null
+++ b/source/MRTest/MRZipCompressTests.cpp
@@ -0,0 +1,191 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace MR
+{
+
+// Writes a sphere to a .mrmesh file in a temporary folder, then
+// compresses that folder to a .zip and verifies the archive was created and
+// is non-empty. Serves as a realistic end-to-end exercise of MeshLib's zip
+// write path (libzip + deflate) on mesh-sized data.
+TEST( MRMesh, CompressSphereToZip )
+{
+ UniqueTemporaryFolder srcFolder;
+ ASSERT_TRUE( bool( srcFolder ) );
+
+ constexpr int targetVerts = 1000; // increase it to make the file being compressed larger, 100'000 vertices -> 12M bytes
+ SphereParams params;
+ params.radius = 1.0f;
+ params.numMeshVertices = targetVerts;
+ const Mesh sphere = makeSphere( params );
+ EXPECT_EQ( (int)sphere.topology.numValidVerts(), targetVerts );
+
+ // Save mesh as a .mrmesh file in the temp folder.
+ const std::filesystem::path meshPath = srcFolder / "sphere.mrmesh";
+ const auto saveRes = MeshSave::toMrmesh( sphere, meshPath );
+ ASSERT_TRUE( saveRes.has_value() ) << saveRes.error();
+ std::error_code ec;
+ ASSERT_TRUE( std::filesystem::exists( meshPath, ec ) );
+ const auto meshSize = std::filesystem::file_size( meshPath, ec );
+ EXPECT_GT( meshSize, 0u );
+ spdlog::info( "sphere.mrmesh size: {} bytes", meshSize );
+
+ // Compress the temp folder into a .zip located in a second temp folder
+ // (so the zip isn't inside the folder being compressed).
+ UniqueTemporaryFolder dstFolder;
+ ASSERT_TRUE( bool( dstFolder ) );
+ const std::filesystem::path zipPath = dstFolder / "sphere.zip";
+
+ const auto compressRes = compressZip( zipPath, srcFolder );
+ ASSERT_TRUE( compressRes.has_value() ) << compressRes.error();
+ ASSERT_TRUE( std::filesystem::exists( zipPath, ec ) );
+ const auto zipSize = std::filesystem::file_size( zipPath, ec );
+ EXPECT_GT( zipSize, 0u );
+ spdlog::info( "sphere.zip size: {} bytes", zipSize );
+
+ // Sanity: the zip should not be absurdly larger than the source
+ // (that would indicate something is wrong with the envelope); and
+ // since .mrmesh is a raw binary dump of topology plus coordinate
+ // floats, deflate typically produces a modestly smaller archive.
+ EXPECT_LT( zipSize, meshSize * 2u );
+}
+
+// Writes many binary files and same number JSON files to a temporary folder, then
+// compresses the folder to a .zip. Pairs with CompressSphereToZip to compare
+// compression of one large binary vs many small mixed-type entries.
+//
+// libzip compresses each entry independently, so per-entry overhead (local
+// file header, CRC32 pass, separate deflate session) can dominate when the
+// archive is made of many small files. This test makes that cost visible.
+TEST( MRMesh, CompressManySmallFilesToZip )
+{
+ UniqueTemporaryFolder srcFolder;
+ ASSERT_TRUE( bool( srcFolder ) );
+
+ // increase both below numbers to make the files being compressed larger, 200 * 2 files * 60'000 bytes -> 24M bytes
+ constexpr int numBinaryFiles = 20;
+ constexpr int numJsonFiles = numBinaryFiles;
+ constexpr size_t bytesPerFile = 6000;
+
+ // Simple LCG used to produce deterministic pseudo-random bytes.
+ // Keeps the test reproducible across runs and platforms while avoiding
+ // trivially-compressible input (an all-zeros buffer would make deflate
+ // look unrealistically good).
+ auto nextLcg = []( uint64_t & state ) -> uint64_t
+ {
+ state = state * 6364136223846793005ULL + 1442695040888963407ULL;
+ return state;
+ };
+
+ auto makeName = []( const char * prefix, int i, const char * ext )
+ {
+ char buf[64];
+ std::snprintf( buf, sizeof( buf ), "%s_%03d.%s", prefix, i, ext );
+ return std::string( buf );
+ };
+
+ // Binary files of pseudo-random bytes. Poor compressibility on
+ // purpose — representative of mesh coordinate floats, compressed-texture
+ // blobs, and other near-incompressible payloads that often live in a
+ // MeshLib scene save.
+ std::size_t totalBinaryBytes = 0;
+ std::vector binBuf( bytesPerFile );
+ for ( int i = 0; i < numBinaryFiles; ++i )
+ {
+ uint64_t state = 0x1234567890ABCDEFULL ^ ( (uint64_t)i << 1 );
+ for ( size_t j = 0; j < bytesPerFile; ++j )
+ binBuf[j] = (char)( nextLcg( state ) >> 56 );
+
+ const std::filesystem::path p = srcFolder / makeName( "data", i, "bin" );
+ std::ofstream out( p, std::ios::binary );
+ ASSERT_TRUE( out.is_open() );
+ out.write( binBuf.data(), (std::streamsize)binBuf.size() );
+ ASSERT_TRUE( out.good() );
+ out.close();
+ totalBinaryBytes += bytesPerFile;
+ }
+
+ // JSON files of deterministic structured-looking text. Highly
+ // compressible — representative of scene-description metadata, logs,
+ // shader source, and other textual payloads.
+ std::size_t totalJsonBytes = 0;
+ for ( int i = 0; i < numJsonFiles; ++i )
+ {
+ uint64_t state = 0xDEADBEEFCAFEBABEULL ^ ( (uint64_t)i << 1 );
+
+ std::string text;
+ text.reserve( bytesPerFile + 256 );
+ text += "[\n";
+ int idx = 0;
+ while ( text.size() + 96 < bytesPerFile )
+ {
+ if ( idx > 0 )
+ text += ",\n";
+ const uint32_t rx = (uint32_t)( nextLcg( state ) >> 32 );
+ const uint32_t ry = (uint32_t)( nextLcg( state ) >> 32 );
+ const uint32_t rz = (uint32_t)( nextLcg( state ) >> 32 );
+ char line[128];
+ const int n = std::snprintf( line, sizeof( line ),
+ " {\"id\": %d, \"x\": %.6f, \"y\": %.6f, \"z\": %.6f}",
+ idx,
+ (double)rx / 4294967296.0,
+ (double)ry / 4294967296.0,
+ (double)rz / 4294967296.0 );
+ ASSERT_GT( n, 0 );
+ text.append( line, (size_t)n );
+ ++idx;
+ }
+ text += "\n]\n";
+ // Pad to exactly bytesPerFile with trailing spaces so the per-file
+ // size — and therefore the total — is deterministic across runs.
+ // The file is never parsed, so trailing whitespace past the final
+ // ']' is harmless.
+ if ( text.size() < bytesPerFile )
+ text.append( bytesPerFile - text.size(), ' ' );
+ else if ( text.size() > bytesPerFile )
+ text.resize( bytesPerFile );
+
+ const std::filesystem::path p = srcFolder / makeName( "meta", i, "json" );
+ std::ofstream out( p, std::ios::binary );
+ ASSERT_TRUE( out.is_open() );
+ out.write( text.data(), (std::streamsize)text.size() );
+ ASSERT_TRUE( out.good() );
+ out.close();
+ totalJsonBytes += text.size();
+ }
+
+ const std::size_t totalInput = totalBinaryBytes + totalJsonBytes;
+ spdlog::info( "many-files input: {} binary + {} json = {} bytes",
+ totalBinaryBytes, totalJsonBytes, totalInput );
+
+ // Compress to a zip in a separate temp folder.
+ UniqueTemporaryFolder dstFolder;
+ ASSERT_TRUE( bool( dstFolder ) );
+ const std::filesystem::path zipPath = dstFolder / "many.zip";
+
+ const auto compressRes = compressZip( zipPath, srcFolder );
+ ASSERT_TRUE( compressRes.has_value() ) << compressRes.error();
+ std::error_code ec;
+ ASSERT_TRUE( std::filesystem::exists( zipPath, ec ) );
+ const auto zipSize = std::filesystem::file_size( zipPath, ec );
+ EXPECT_GT( zipSize, 0u );
+ spdlog::info( "many.zip size: {} bytes", zipSize );
+
+ // Sanity envelope: same bound as the sphere test.
+ EXPECT_LT( zipSize, totalInput * 2u );
+}
+
+} // namespace MR