Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion include/openscad_cpp_evaluator/export.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ void writeObj(const std::string& path, const std::vector<ColoredBody>& bodies);
void writeOff(const std::string& path, const std::vector<ColoredBody>& bodies);

// 3MF: one mesh object + a base-color colorgroup per body (skipping empty
// bodies), written as a stored (uncompressed) ZIP -- see zip_stored.hpp.
// bodies), written as a DEFLATE-compressed ZIP (writeDeflateZip, see
// zip_stored.hpp) -- the XML is highly compressible, so storing it would
// make the file several times larger for nothing. An entry that does not
// actually shrink is stored raw, as any ZIP writer would.
// Mirrors export.py's write_3mf's XML shape exactly (core + material
// namespaces, %.6g vertex formatting); throws std::runtime_error if there's
// no geometry to export.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"

[project]
name = "openscad_cpp_evaluator"
version = "0.29.1"
version = "0.29.2"
description = "C++ OpenSCAD evaluator with Python bindings"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
34 changes: 34 additions & 0 deletions tests/test_import_export.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,40 @@ TEST(ImportModuleContext, ThreeMfRoundTripPreservesVolume) {
std::filesystem::remove(path);
}

// The .3mf writeThreeMf() produces is DEFLATE-compressed, not stored. The
// round-trip test above passes either way -- the reader accepts both
// methods -- so without this a regression to STORED would show up only as
// files several times bigger than they need to be.
TEST(ImportModuleContext, ThreeMfIsDeflateCompressed) {
const auto path = tempPath("cube_compressed.3mf");
writeCubeAs(path, &writeThreeMf);

std::ifstream in(path, std::ios::binary);
ASSERT_TRUE(in);
std::vector<uint8_t> buf((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
in.close();

bool sawModel = false;
for (size_t i = 0; i + 46 <= buf.size(); ++i) {
if (!(buf[i] == 0x50 && buf[i + 1] == 0x4B && buf[i + 2] == 0x01 && buf[i + 3] == 0x02)) continue;
const uint16_t method = static_cast<uint16_t>(buf[i + 10] | (buf[i + 11] << 8));
const uint32_t compressedSize =
static_cast<uint32_t>(buf[i + 20] | (buf[i + 21] << 8) | (buf[i + 22] << 16) | (buf[i + 23] << 24));
const uint32_t rawSize =
static_cast<uint32_t>(buf[i + 24] | (buf[i + 25] << 8) | (buf[i + 26] << 16) | (buf[i + 27] << 24));
const uint16_t nameLen = static_cast<uint16_t>(buf[i + 28] | (buf[i + 29] << 8));
if (i + 46 + nameLen > buf.size()) continue;
const std::string name(reinterpret_cast<const char*>(&buf[i + 46]), nameLen);
if (name.find("3dmodel.model") == std::string::npos) continue;
sawModel = true;
EXPECT_EQ(method, 8) << "3dmodel.model stored uncompressed";
EXPECT_LT(compressedSize, rawSize) << "compressed " << compressedSize << " vs raw " << rawSize;
}
EXPECT_TRUE(sawModel) << "no 3dmodel.model entry in the archive";

std::filesystem::remove(path);
}

TEST(ImportModuleContext, UnsupportedExtensionErrors) {
Evaluator ev;
auto ast = parseSrc("import(\"nope.xyz\");");
Expand Down
84 changes: 84 additions & 0 deletions tests/test_zip_stored.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// Direct tests for zip_stored.hpp -- writeStoredZip()'s own STORED-entry
// round trip, plus reading a DEFLATE-compressed (method 8) entry, which is
// what most third-party ZIP/3MF writers actually emit.
//
// Also what writeDeflateZip() and writeThreeMf() put in the archive. That
// went untested: the round-trip tests pass either way, since the reader
// handles both methods, so a silent regression to STORED would only show
// up as files several times larger. The method byte is read out of the
// central directory here so it cannot regress unnoticed.

#include "openscad_cpp_evaluator/zip_stored.hpp"

Expand Down Expand Up @@ -169,3 +175,81 @@ TEST(ZipStored, UnsupportedCompressionMethodThrows) {
EXPECT_THROW(readStoredZipEntry(path.string(), "hello.txt"), std::runtime_error);
std::filesystem::remove(path);
}


// --- what we WRITE, not just what we can read -------------------------

namespace {

// The compression method of the first central-directory entry whose name
// ends with `suffix`. Read from the central directory rather than the local
// header, since that is the authoritative copy.
uint16_t centralDirMethodBySuffix(const std::string& path, const std::string& suffix) {
std::ifstream in(path, std::ios::binary);
EXPECT_TRUE(in) << "cannot open " << path;
std::vector<uint8_t> buf((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
// Walk central-directory headers by signature; these archives are small
// and have no ZIP comment, so a forward scan is enough.
for (size_t i = 0; i + 46 <= buf.size(); ++i) {
if (!(buf[i] == 0x50 && buf[i + 1] == 0x4B && buf[i + 2] == 0x01 && buf[i + 3] == 0x02)) continue;
const uint16_t method = static_cast<uint16_t>(buf[i + 10] | (buf[i + 11] << 8));
const uint16_t nameLen = static_cast<uint16_t>(buf[i + 28] | (buf[i + 29] << 8));
if (i + 46 + nameLen > buf.size()) continue;
const std::string name(reinterpret_cast<const char*>(&buf[i + 46]), nameLen);
if (name.size() >= suffix.size() && name.compare(name.size() - suffix.size(), suffix.size(), suffix) == 0)
return method;
}
ADD_FAILURE() << "no central-directory entry ending in " << suffix;
return 0xFFFF;
}

// Compressible: long runs, which is what 3MF's indented XML looks like.
std::vector<uint8_t> compressibleBytes(size_t n) {
std::vector<uint8_t> out;
out.reserve(n);
const std::string unit = "\t\t\t\t\t<vertex x=\"0\" y=\"0\" z=\"0\" />\n";
while (out.size() < n) out.insert(out.end(), unit.begin(), unit.end());
out.resize(n);
return out;
}

} // namespace

TEST(ZipStored, DeflateZipReallyWritesMethod8) {
const auto path = tempPath("deflate_method.zip");
const auto data = compressibleBytes(64 * 1024);
writeDeflateZip(path.string(), {ZipEntry{"3D/3dmodel.model", data}});
EXPECT_EQ(centralDirMethodBySuffix(path.string(), "3dmodel.model"), 8);
// And it still reads back byte for byte.
EXPECT_EQ(readStoredZipEntry(path.string(), "3D/3dmodel.model"), data);
std::filesystem::remove(path);
}

TEST(ZipStored, DeflateZipActuallyShrinksCompressibleData) {
const auto path = tempPath("deflate_size.zip");
const auto data = compressibleBytes(256 * 1024);
writeDeflateZip(path.string(), {ZipEntry{"3D/3dmodel.model", data}});
const auto onDisk = std::filesystem::file_size(path);
// Highly repetitive input; anything near its original size means the
// compressor did not run.
EXPECT_LT(onDisk, data.size() / 4) << "archive " << onDisk << " for " << data.size() << " bytes of input";
std::filesystem::remove(path);
}

TEST(ZipStored, DeflateZipStoresDataThatWouldNotShrink) {
const auto path = tempPath("deflate_tiny.zip");
const std::vector<uint8_t> tiny{'x'};
writeDeflateZip(path.string(), {ZipEntry{"a.txt", tiny}});
EXPECT_EQ(centralDirMethodBySuffix(path.string(), "a.txt"), 0);
EXPECT_EQ(readStoredZipEntry(path.string(), "a.txt"), tiny);
std::filesystem::remove(path);
}

TEST(ZipStored, StoredZipWritesMethod0) {
// The control for the tests above: same call shape, opposite method.
const auto path = tempPath("stored_method.zip");
const auto data = compressibleBytes(64 * 1024);
writeStoredZip(path.string(), {ZipEntry{"3D/3dmodel.model", data}});
EXPECT_EQ(centralDirMethodBySuffix(path.string(), "3dmodel.model"), 0);
std::filesystem::remove(path);
}